Merge branch 'release/1.15.0'

pull/1320/head 1.15.0
Anthony Lapenna 2017-10-15 19:27:34 +02:00
commit 5785ba5f4a
129 changed files with 4599 additions and 1724 deletions

View File

@ -25,6 +25,7 @@ type Store struct {
SettingsService *SettingsService SettingsService *SettingsService
RegistryService *RegistryService RegistryService *RegistryService
DockerHubService *DockerHubService DockerHubService *DockerHubService
StackService *StackService
db *bolt.DB db *bolt.DB
checkForDataMigration bool checkForDataMigration bool
@ -41,6 +42,7 @@ const (
settingsBucketName = "settings" settingsBucketName = "settings"
registryBucketName = "registries" registryBucketName = "registries"
dockerhubBucketName = "dockerhub" dockerhubBucketName = "dockerhub"
stackBucketName = "stacks"
) )
// NewStore initializes a new Store and the associated services // NewStore initializes a new Store and the associated services
@ -56,6 +58,7 @@ func NewStore(storePath string) (*Store, error) {
SettingsService: &SettingsService{}, SettingsService: &SettingsService{},
RegistryService: &RegistryService{}, RegistryService: &RegistryService{},
DockerHubService: &DockerHubService{}, DockerHubService: &DockerHubService{},
StackService: &StackService{},
} }
store.UserService.store = store store.UserService.store = store
store.TeamService.store = store store.TeamService.store = store
@ -66,6 +69,7 @@ func NewStore(storePath string) (*Store, error) {
store.SettingsService.store = store store.SettingsService.store = store
store.RegistryService.store = store store.RegistryService.store = store
store.DockerHubService.store = store store.DockerHubService.store = store
store.StackService.store = store
_, err := os.Stat(storePath + "/" + databaseFileName) _, err := os.Stat(storePath + "/" + databaseFileName)
if err != nil && os.IsNotExist(err) { if err != nil && os.IsNotExist(err) {
@ -91,7 +95,7 @@ func (store *Store) Open() error {
bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName, bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName,
resourceControlBucketName, teamMembershipBucketName, settingsBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
registryBucketName, dockerhubBucketName} registryBucketName, dockerhubBucketName, stackBucketName}
return db.Update(func(tx *bolt.Tx) error { return db.Update(func(tx *bolt.Tx) error {

View File

@ -47,6 +47,16 @@ func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error {
return json.Unmarshal(data, endpoint) return json.Unmarshal(data, endpoint)
} }
// MarshalStack encodes a stack to binary format.
func MarshalStack(stack *portainer.Stack) ([]byte, error) {
return json.Marshal(stack)
}
// UnmarshalStack decodes a stack from a binary data.
func UnmarshalStack(data []byte, stack *portainer.Stack) error {
return json.Unmarshal(data, stack)
}
// MarshalRegistry encodes a registry to binary format. // MarshalRegistry encodes a registry to binary format.
func MarshalRegistry(registry *portainer.Registry) ([]byte, error) { func MarshalRegistry(registry *portainer.Registry) ([]byte, error) {
return json.Marshal(registry) return json.Marshal(registry)

View File

@ -0,0 +1,16 @@
package bolt
func (m *Migrator) updateSettingsToVersion5() error {
legacySettings, err := m.SettingsService.Settings()
if err != nil {
return err
}
legacySettings.AllowBindMountsForRegularUsers = true
err = m.SettingsService.StoreSettings(legacySettings)
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,16 @@
package bolt
func (m *Migrator) updateSettingsToVersion6() error {
legacySettings, err := m.SettingsService.Settings()
if err != nil {
return err
}
legacySettings.AllowPrivilegedModeForRegularUsers = true
err = m.SettingsService.StoreSettings(legacySettings)
if err != nil {
return err
}
return nil
}

View File

@ -65,6 +65,22 @@ func (m *Migrator) Migrate() error {
} }
} }
// https://github.com/portainer/portainer/issues/1235
if m.CurrentDBVersion < 5 {
err := m.updateSettingsToVersion5()
if err != nil {
return err
}
}
// https://github.com/portainer/portainer/issues/1236
if m.CurrentDBVersion < 6 {
err := m.updateSettingsToVersion6()
if err != nil {
return err
}
}
err := m.VersionService.StoreDBVersion(portainer.DBVersion) err := m.VersionService.StoreDBVersion(portainer.DBVersion)
if err != nil { if err != nil {
return err return err

138
api/bolt/stack_service.go Normal file
View File

@ -0,0 +1,138 @@
package bolt
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
// StackService represents a service for managing stacks.
type StackService struct {
store *Store
}
// Stack returns a stack object by ID.
func (service *StackService) Stack(ID portainer.StackID) (*portainer.Stack, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(stackBucketName))
value := bucket.Get([]byte(ID))
if value == nil {
return portainer.ErrStackNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
var stack portainer.Stack
err = internal.UnmarshalStack(data, &stack)
if err != nil {
return nil, err
}
return &stack, nil
}
// Stacks returns an array containing all the stacks.
func (service *StackService) Stacks() ([]portainer.Stack, error) {
var stacks = make([]portainer.Stack, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(stackBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var stack portainer.Stack
err := internal.UnmarshalStack(v, &stack)
if err != nil {
return err
}
stacks = append(stacks, stack)
}
return nil
})
if err != nil {
return nil, err
}
return stacks, nil
}
// StacksBySwarmID return an array containing all the stacks related to the specified Swarm ID.
func (service *StackService) StacksBySwarmID(id string) ([]portainer.Stack, error) {
var stacks = make([]portainer.Stack, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(stackBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var stack portainer.Stack
err := internal.UnmarshalStack(v, &stack)
if err != nil {
return err
}
if stack.SwarmID == id {
stacks = append(stacks, stack)
}
}
return nil
})
if err != nil {
return nil, err
}
return stacks, nil
}
// CreateStack creates a new stack.
func (service *StackService) CreateStack(stack *portainer.Stack) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(stackBucketName))
data, err := internal.MarshalStack(stack)
if err != nil {
return err
}
err = bucket.Put([]byte(stack.ID), data)
if err != nil {
return err
}
return nil
})
}
// UpdateStack updates an stack.
func (service *StackService) UpdateStack(ID portainer.StackID, stack *portainer.Stack) error {
data, err := internal.MarshalStack(stack)
if err != nil {
return err
}
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(stackBucketName))
err = bucket.Put([]byte(ID), data)
if err != nil {
return err
}
return nil
})
}
// DeleteStack deletes an stack.
func (service *StackService) DeleteStack(ID portainer.StackID) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(stackBucketName))
err := bucket.Delete([]byte(ID))
if err != nil {
return err
}
return nil
})
}

View File

@ -21,7 +21,8 @@ const (
errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file") errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file")
errInvalidSyncInterval = portainer.Error("Invalid synchronization interval") errInvalidSyncInterval = portainer.Error("Invalid synchronization interval")
errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints") errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints")
errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password") errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password or --admin-password-file")
errAdminPassExcludeAdminPassFile = portainer.Error("Cannot use --admin-password with --admin-password-file")
) )
// ParseFlags parse the CLI flags and return a portainer.Flags struct // ParseFlags parse the CLI flags and return a portainer.Flags struct
@ -45,6 +46,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(), SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(),
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(), SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(),
AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(), AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(),
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(),
// Deprecated flags // Deprecated flags
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')), Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(), Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),
@ -77,10 +79,14 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
return err return err
} }
if *flags.NoAuth && (*flags.AdminPassword != "") { if *flags.NoAuth && (*flags.AdminPassword != "" || *flags.AdminPasswordFile != "") {
return errNoAuthExcludeAdminPassword return errNoAuthExcludeAdminPassword
} }
if *flags.AdminPassword != "" && *flags.AdminPasswordFile != "" {
return errAdminPassExcludeAdminPassFile
}
displayDeprecationWarnings(*flags.Templates, *flags.Logo, *flags.Labels) displayDeprecationWarnings(*flags.Templates, *flags.Logo, *flags.Labels)
return nil return nil

View File

@ -6,7 +6,9 @@ import (
"github.com/portainer/portainer/cli" "github.com/portainer/portainer/cli"
"github.com/portainer/portainer/cron" "github.com/portainer/portainer/cron"
"github.com/portainer/portainer/crypto" "github.com/portainer/portainer/crypto"
"github.com/portainer/portainer/exec"
"github.com/portainer/portainer/file" "github.com/portainer/portainer/file"
"github.com/portainer/portainer/git"
"github.com/portainer/portainer/http" "github.com/portainer/portainer/http"
"github.com/portainer/portainer/jwt" "github.com/portainer/portainer/jwt"
"github.com/portainer/portainer/ldap" "github.com/portainer/portainer/ldap"
@ -54,6 +56,10 @@ func initStore(dataStorePath string) *bolt.Store {
return store return store
} }
func initStackManager(assetsPath string) portainer.StackManager {
return exec.NewStackManager(assetsPath)
}
func initJWTService(authenticationEnabled bool) portainer.JWTService { func initJWTService(authenticationEnabled bool) portainer.JWTService {
if authenticationEnabled { if authenticationEnabled {
jwtService, err := jwt.NewService() jwtService, err := jwt.NewService()
@ -73,6 +79,10 @@ func initLDAPService() portainer.LDAPService {
return &ldap.Service{} return &ldap.Service{}
} }
func initGitService() portainer.GitService {
return &git.Service{}
}
func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool { func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool {
authorizeEndpointMgmt := true authorizeEndpointMgmt := true
if externalEnpointFile != "" { if externalEnpointFile != "" {
@ -125,6 +135,8 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
portainer.LDAPSearchSettings{}, portainer.LDAPSearchSettings{},
}, },
}, },
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
} }
if *flags.Templates != "" { if *flags.Templates != "" {
@ -163,12 +175,16 @@ func main() {
store := initStore(*flags.Data) store := initStore(*flags.Data)
defer store.Close() defer store.Close()
stackManager := initStackManager(*flags.Assets)
jwtService := initJWTService(!*flags.NoAuth) jwtService := initJWTService(!*flags.NoAuth)
cryptoService := initCryptoService() cryptoService := initCryptoService()
ldapService := initLDAPService() ldapService := initLDAPService()
gitService := initGitService()
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval) authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
err := initSettings(store.SettingsService, flags) err := initSettings(store.SettingsService, flags)
@ -184,7 +200,6 @@ func main() {
applicationStatus := initStatus(authorizeEndpointMgmt, flags) applicationStatus := initStatus(authorizeEndpointMgmt, flags)
if *flags.Endpoint != "" { if *flags.Endpoint != "" {
var endpoints []portainer.Endpoint
endpoints, err := store.EndpointService.Endpoints() endpoints, err := store.EndpointService.Endpoints()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@ -212,17 +227,40 @@ func main() {
} }
} }
if *flags.AdminPassword != "" { adminPasswordHash := ""
log.Printf("Creating admin user with password hash %s", *flags.AdminPassword) if *flags.AdminPasswordFile != "" {
content, err := fileService.GetFileContent(*flags.AdminPasswordFile)
if err != nil {
log.Fatal(err)
}
adminPasswordHash, err = cryptoService.Hash(content)
if err != nil {
log.Fatal(err)
}
} else if *flags.AdminPassword != "" {
adminPasswordHash = *flags.AdminPassword
}
if adminPasswordHash != "" {
users, err := store.UserService.UsersByRole(portainer.AdministratorRole)
if err != nil {
log.Fatal(err)
}
if len(users) == 0 {
log.Printf("Creating admin user with password hash %s", adminPasswordHash)
user := &portainer.User{ user := &portainer.User{
Username: "admin", Username: "admin",
Role: portainer.AdministratorRole, Role: portainer.AdministratorRole,
Password: *flags.AdminPassword, Password: adminPasswordHash,
} }
err := store.UserService.CreateUser(user) err := store.UserService.CreateUser(user)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
} else {
log.Println("Instance already has an administrator user defined. Skipping admin password related flags.")
}
} }
var server portainer.Server = &http.Server{ var server portainer.Server = &http.Server{
@ -239,10 +277,13 @@ func main() {
SettingsService: store.SettingsService, SettingsService: store.SettingsService,
RegistryService: store.RegistryService, RegistryService: store.RegistryService,
DockerHubService: store.DockerHubService, DockerHubService: store.DockerHubService,
StackService: store.StackService,
StackManager: stackManager,
CryptoService: cryptoService, CryptoService: cryptoService,
JWTService: jwtService, JWTService: jwtService,
FileService: fileService, FileService: fileService,
LDAPService: ldapService, LDAPService: ldapService,
GitService: gitService,
SSL: *flags.SSL, SSL: *flags.SSL,
SSLCert: *flags.SSLCert, SSLCert: *flags.SSLCert,
SSLKey: *flags.SSLKey, SSLKey: *flags.SSLKey,

View File

@ -50,6 +50,13 @@ const (
ErrRegistryAlreadyExists = Error("A registry is already defined for this URL") ErrRegistryAlreadyExists = Error("A registry is already defined for this URL")
) )
// Stack errors
const (
ErrStackNotFound = Error("Stack not found")
ErrStackAlreadyExists = Error("A stack already exists with this name")
ErrComposeFileNotFoundInRepository = Error("Unable to find a Compose file in the repository")
)
// Version errors. // Version errors.
const ( const (
ErrDBVersionNotFound = Error("DB version not found") ErrDBVersionNotFound = Error("DB version not found")

76
api/exec/stack_manager.go Normal file
View File

@ -0,0 +1,76 @@
package exec
import (
"bytes"
"os/exec"
"path"
"runtime"
"github.com/portainer/portainer"
)
// StackManager represents a service for managing stacks.
type StackManager struct {
binaryPath string
}
// NewStackManager initializes a new StackManager service.
func NewStackManager(binaryPath string) *StackManager {
return &StackManager{
binaryPath: binaryPath,
}
}
// Deploy will execute 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)
}
// Remove will execute 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)
}
func runCommandAndCaptureStdErr(command string, args []string) error {
var stderr bytes.Buffer
cmd := exec.Command(command, args...)
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return portainer.Error(stderr.String())
}
return nil
}
func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint) (string, []string) {
// Assume Linux as a default
command := path.Join(binaryPath, "docker")
if runtime.GOOS == "windows" {
command = path.Join(binaryPath, "docker.exe")
}
args := make([]string, 0)
args = append(args, "-H", endpoint.URL)
if endpoint.TLSConfig.TLS {
args = append(args, "--tls")
if !endpoint.TLSConfig.TLSSkipVerify {
args = append(args, "--tlsverify", "--tlscacert", endpoint.TLSConfig.TLSCACertPath)
}
if endpoint.TLSConfig.TLSCertPath != "" && endpoint.TLSConfig.TLSKeyPath != "" {
args = append(args, "--tlscert", endpoint.TLSConfig.TLSCertPath, "--tlskey", endpoint.TLSConfig.TLSKeyPath)
}
}
return command, args
}

View File

@ -1,6 +1,9 @@
package file package file
import ( import (
"bytes"
"io/ioutil"
"github.com/portainer/portainer" "github.com/portainer/portainer"
"io" "io"
@ -19,6 +22,10 @@ const (
TLSCertFile = "cert.pem" TLSCertFile = "cert.pem"
// TLSKeyFile represents the name on disk for a TLS key file. // TLSKeyFile represents the name on disk for a TLS key file.
TLSKeyFile = "key.pem" TLSKeyFile = "key.pem"
// ComposeStorePath represents the subfolder where compose files are stored in the file store folder.
ComposeStorePath = "compose"
// ComposeFileDefaultName represents the default name of a compose file.
ComposeFileDefaultName = "docker-compose.yml"
) )
// Service represents a service for managing files and directories. // Service represents a service for managing files and directories.
@ -48,9 +55,65 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) {
return nil, err return nil, err
} }
err = service.createDirectoryInStoreIfNotExist(ComposeStorePath)
if err != nil {
return nil, err
}
return service, nil return service, nil
} }
// RemoveDirectory removes a directory on the filesystem.
func (service *Service) RemoveDirectory(directoryPath string) error {
return os.RemoveAll(directoryPath)
}
// GetStackProjectPath returns the absolute path on the FS for a stack based
// on its identifier.
func (service *Service) GetStackProjectPath(stackIdentifier string) string {
return path.Join(service.fileStorePath, ComposeStorePath, stackIdentifier)
}
// StoreStackFileFromString creates a subfolder in the ComposeStorePath and stores a new file using the content from a string.
// It returns the path to the folder where the file is stored.
func (service *Service) StoreStackFileFromString(stackIdentifier, stackFileContent string) (string, error) {
stackStorePath := path.Join(ComposeStorePath, stackIdentifier)
err := service.createDirectoryInStoreIfNotExist(stackStorePath)
if err != nil {
return "", err
}
composeFilePath := path.Join(stackStorePath, ComposeFileDefaultName)
data := []byte(stackFileContent)
r := bytes.NewReader(data)
err = service.createFileInStore(composeFilePath, r)
if err != nil {
return "", err
}
return path.Join(service.fileStorePath, stackStorePath), nil
}
// StoreStackFileFromReader creates a subfolder in the ComposeStorePath and stores a new file using the content from an io.Reader.
// It returns the path to the folder where the file is stored.
func (service *Service) StoreStackFileFromReader(stackIdentifier string, r io.Reader) (string, error) {
stackStorePath := path.Join(ComposeStorePath, stackIdentifier)
err := service.createDirectoryInStoreIfNotExist(stackStorePath)
if err != nil {
return "", err
}
composeFilePath := path.Join(stackStorePath, ComposeFileDefaultName)
err = service.createFileInStore(composeFilePath, r)
if err != nil {
return "", err
}
return path.Join(service.fileStorePath, stackStorePath), nil
}
// StoreTLSFile creates a folder in the TLSStorePath and stores a new file with the content from r. // StoreTLSFile creates a folder in the TLSStorePath and stores a new file with the content from r.
func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileType, r io.Reader) error { func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileType, r io.Reader) error {
storePath := path.Join(TLSStorePath, folder) storePath := path.Join(TLSStorePath, folder)
@ -128,6 +191,16 @@ func (service *Service) DeleteTLSFile(folder string, fileType portainer.TLSFileT
return nil return nil
} }
// GetFileContent returns a string content from file.
func (service *Service) GetFileContent(filePath string) (string, error) {
content, err := ioutil.ReadFile(filePath)
if err != nil {
return "", err
}
return string(content), nil
}
// createDirectoryInStoreIfNotExist creates a new directory in the file store if it doesn't exists on the file system. // createDirectoryInStoreIfNotExist creates a new directory in the file store if it doesn't exists on the file system.
func (service *Service) createDirectoryInStoreIfNotExist(name string) error { func (service *Service) createDirectoryInStoreIfNotExist(name string) error {
path := path.Join(service.fileStorePath, name) path := path.Join(service.fileStorePath, name)
@ -151,14 +224,17 @@ func createDirectoryIfNotExist(path string, mode uint32) error {
// createFile creates a new file in the file store with the content from r. // createFile creates a new file in the file store with the content from r.
func (service *Service) createFileInStore(filePath string, r io.Reader) error { func (service *Service) createFileInStore(filePath string, r io.Reader) error {
path := path.Join(service.fileStorePath, filePath) path := path.Join(service.fileStorePath, filePath)
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil { if err != nil {
return err return err
} }
defer out.Close() defer out.Close()
_, err = io.Copy(out, r) _, err = io.Copy(out, r)
if err != nil { if err != nil {
return err return err
} }
return nil return nil
} }

25
api/git/git.go Normal file
View File

@ -0,0 +1,25 @@
package git
import (
"gopkg.in/src-d/go-git.v4"
)
// Service represents a service for managing Git.
type Service struct{}
// NewService initializes a new service.
func NewService(dataStorePath string) (*Service, error) {
service := &Service{}
return service, nil
}
// CloneRepository clones a git repository using the specified URL in the specified
// destination folder.
func (service *Service) CloneRepository(url, destination string) error {
_, err := git.PlainClone(destination, false, &git.CloneOptions{
URL: url,
})
return err
}

View File

@ -22,7 +22,7 @@ type DockerHubHandler struct {
DockerHubService portainer.DockerHubService DockerHubService portainer.DockerHubService
} }
// NewDockerHubHandler returns a new instance of NewDockerHubHandler. // NewDockerHubHandler returns a new instance of DockerHubHandler.
func NewDockerHubHandler(bouncer *security.RequestBouncer) *DockerHubHandler { func NewDockerHubHandler(bouncer *security.RequestBouncer) *DockerHubHandler {
h := &DockerHubHandler{ h := &DockerHubHandler{
Router: mux.NewRouter(), Router: mux.NewRouter(),

View File

@ -20,6 +20,7 @@ type Handler struct {
RegistryHandler *RegistryHandler RegistryHandler *RegistryHandler
DockerHubHandler *DockerHubHandler DockerHubHandler *DockerHubHandler
ResourceHandler *ResourceHandler ResourceHandler *ResourceHandler
StackHandler *StackHandler
StatusHandler *StatusHandler StatusHandler *StatusHandler
SettingsHandler *SettingsHandler SettingsHandler *SettingsHandler
TemplatesHandler *TemplatesHandler TemplatesHandler *TemplatesHandler
@ -49,6 +50,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case strings.HasPrefix(r.URL.Path, "/api/endpoints"): case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
if strings.Contains(r.URL.Path, "/docker") { if strings.Contains(r.URL.Path, "/docker") {
http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r) http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r)
} else if strings.Contains(r.URL.Path, "/stacks") {
http.StripPrefix("/api/endpoints", h.StackHandler).ServeHTTP(w, r)
} else { } else {
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
} }

View File

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

View File

@ -48,6 +48,8 @@ type (
LogoURL string `json:"LogoURL"` LogoURL string `json:"LogoURL"`
DisplayExternalContributors bool `json:"DisplayExternalContributors"` DisplayExternalContributors bool `json:"DisplayExternalContributors"`
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
} }
putSettingsRequest struct { putSettingsRequest struct {
@ -57,6 +59,8 @@ type (
DisplayExternalContributors bool `valid:""` DisplayExternalContributors bool `valid:""`
AuthenticationMethod int `valid:"required"` AuthenticationMethod int `valid:"required"`
LDAPSettings portainer.LDAPSettings `valid:""` LDAPSettings portainer.LDAPSettings `valid:""`
AllowBindMountsForRegularUsers bool `valid:""`
AllowPrivilegedModeForRegularUsers bool `valid:""`
} }
putSettingsLDAPCheckRequest struct { putSettingsLDAPCheckRequest struct {
@ -88,6 +92,8 @@ func (handler *SettingsHandler) handleGetPublicSettings(w http.ResponseWriter, r
LogoURL: settings.LogoURL, LogoURL: settings.LogoURL,
DisplayExternalContributors: settings.DisplayExternalContributors, DisplayExternalContributors: settings.DisplayExternalContributors,
AuthenticationMethod: settings.AuthenticationMethod, AuthenticationMethod: settings.AuthenticationMethod,
AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers,
AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers,
} }
encodeJSON(w, publicSettings, handler.Logger) encodeJSON(w, publicSettings, handler.Logger)
@ -114,6 +120,8 @@ func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http
BlackListedLabels: req.BlackListedLabels, BlackListedLabels: req.BlackListedLabels,
DisplayExternalContributors: req.DisplayExternalContributors, DisplayExternalContributors: req.DisplayExternalContributors,
LDAPSettings: req.LDAPSettings, LDAPSettings: req.LDAPSettings,
AllowBindMountsForRegularUsers: req.AllowBindMountsForRegularUsers,
AllowPrivilegedModeForRegularUsers: req.AllowPrivilegedModeForRegularUsers,
} }
if req.AuthenticationMethod == 1 { if req.AuthenticationMethod == 1 {

609
api/http/handler/stack.go Normal file
View File

@ -0,0 +1,609 @@
package handler
import (
"encoding/json"
"path"
"strconv"
"strings"
"github.com/asaskevich/govalidator"
"github.com/portainer/portainer"
"github.com/portainer/portainer/file"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
)
// StackHandler represents an HTTP API handler for managing Stack.
type StackHandler struct {
*mux.Router
Logger *log.Logger
FileService portainer.FileService
GitService portainer.GitService
StackService portainer.StackService
EndpointService portainer.EndpointService
ResourceControlService portainer.ResourceControlService
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),
}
h.Handle("/{endpointId}/stacks",
bouncer.AuthenticatedAccess(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}",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStack))).Methods(http.MethodGet)
h.Handle("/{endpointId}/stacks/{id}",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteStack))).Methods(http.MethodDelete)
h.Handle("/{endpointId}/stacks/{id}",
bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutStack))).Methods(http.MethodPut)
h.Handle("/{endpointId}/stacks/{id}/stackfile",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStackFile))).Methods(http.MethodGet)
return h
}
type (
postStacksRequest struct {
Name string `valid:"required"`
SwarmID string `valid:"required"`
StackFileContent string `valid:""`
GitRepository string `valid:""`
PathInRepository string `valid:""`
}
postStacksResponse struct {
ID string `json:"Id"`
}
getStackFileResponse struct {
StackFileContent string `json:"StackFileContent"`
}
putStackRequest struct {
StackFileContent string `valid:"required"`
}
)
// handlePostStacks handles POST requests on /:endpointId/stacks?method=<method>
func (handler *StackHandler) handlePostStacks(w http.ResponseWriter, r *http.Request) {
method := r.FormValue("method")
if method == "" {
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
return
}
if method == "string" {
handler.handlePostStacksStringMethod(w, r)
} else if method == "repository" {
handler.handlePostStacksRepositoryMethod(w, r)
} else if method == "file" {
handler.handlePostStacksFileMethod(w, r)
} else {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
}
func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointID := portainer.EndpointID(id)
endpoint, err := handler.EndpointService.Endpoint(endpointID)
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
var req postStacksRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
stackName := req.Name
if stackName == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
stackFileContent := req.StackFileContent
if stackFileContent == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
swarmID := req.SwarmID
if swarmID == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
stacks, err := handler.StackService.Stacks()
if err != nil && err != portainer.ErrStackNotFound {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
for _, stack := range stacks {
if strings.EqualFold(stack.Name, stackName) {
httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger)
return
}
}
stack := &portainer.Stack{
ID: portainer.StackID(stackName + "_" + swarmID),
Name: stackName,
SwarmID: swarmID,
EntryPoint: file.ComposeFileDefaultName,
}
projectPath, err := handler.FileService.StoreStackFileFromString(string(stack.ID), stackFileContent)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stack.ProjectPath = projectPath
err = handler.StackService.CreateStack(stack)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.StackManager.Deploy(stack, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger)
}
func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointID := portainer.EndpointID(id)
endpoint, err := handler.EndpointService.Endpoint(endpointID)
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
var req postStacksRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
stackName := req.Name
if stackName == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
swarmID := req.SwarmID
if swarmID == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
if req.GitRepository == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
if req.PathInRepository == "" {
req.PathInRepository = file.ComposeFileDefaultName
}
stacks, err := handler.StackService.Stacks()
if err != nil && err != portainer.ErrStackNotFound {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
for _, stack := range stacks {
if strings.EqualFold(stack.Name, stackName) {
httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger)
return
}
}
stack := &portainer.Stack{
ID: portainer.StackID(stackName + "_" + swarmID),
Name: stackName,
SwarmID: swarmID,
EntryPoint: req.PathInRepository,
}
projectPath := handler.FileService.GetStackProjectPath(string(stack.ID))
stack.ProjectPath = projectPath
// Ensure projectPath is empty
err = handler.FileService.RemoveDirectory(projectPath)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.GitService.CloneRepository(req.GitRepository, projectPath)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.StackService.CreateStack(stack)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.StackManager.Deploy(stack, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger)
}
func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointID := portainer.EndpointID(id)
endpoint, err := handler.EndpointService.Endpoint(endpointID)
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stackName := r.FormValue("Name")
if stackName == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
swarmID := r.FormValue("SwarmID")
if swarmID == "" {
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)
return
}
defer stackFile.Close()
stacks, err := handler.StackService.Stacks()
if err != nil && err != portainer.ErrStackNotFound {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
for _, stack := range stacks {
if strings.EqualFold(stack.Name, stackName) {
httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger)
return
}
}
stack := &portainer.Stack{
ID: portainer.StackID(stackName + "_" + swarmID),
Name: stackName,
SwarmID: swarmID,
EntryPoint: file.ComposeFileDefaultName,
}
projectPath, err := handler.FileService.StoreStackFileFromReader(string(stack.ID), stackFile)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stack.ProjectPath = projectPath
err = handler.StackService.CreateStack(stack)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.StackManager.Deploy(stack, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger)
}
// handleGetStacks handles GET requests on /:endpointId/stacks?swarmId=<swarmId>
func (handler *StackHandler) handleGetStacks(w http.ResponseWriter, r *http.Request) {
swarmID := r.FormValue("swarmId")
vars := mux.Vars(r)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
id, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointID := portainer.EndpointID(id)
_, err = handler.EndpointService.Endpoint(endpointID)
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
var stacks []portainer.Stack
if swarmID == "" {
stacks, err = handler.StackService.Stacks()
} else {
stacks, err = handler.StackService.StacksBySwarmID(swarmID)
}
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
resourceControls, err := handler.ResourceControlService.ResourceControls()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredStacks := proxy.FilterStacks(stacks, resourceControls, securityContext.IsAdmin,
securityContext.UserID, securityContext.UserMemberships)
encodeJSON(w, filteredStacks, handler.Logger)
}
// handleGetStack handles GET requests on /:endpointId/stacks/:id
func (handler *StackHandler) handleGetStack(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
stackID := vars["id"]
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
endpointID, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
_, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stack, err := handler.StackService.Stack(portainer.StackID(stackID))
if err == portainer.ErrStackNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name)
if err != nil && err != portainer.ErrResourceControlNotFound {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
extendedStack := proxy.ExtendedStack{*stack, portainer.ResourceControl{}}
if resourceControl != nil {
if securityContext.IsAdmin || proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) {
extendedStack.ResourceControl = *resourceControl
} else {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
}
encodeJSON(w, extendedStack, handler.Logger)
}
// handlePutStack handles PUT requests on /:endpointId/stacks/:id
func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
stackID := vars["id"]
endpointID, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stack, err := handler.StackService.Stack(portainer.StackID(stackID))
if err == portainer.ErrStackNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
var req putStackRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
_, err = handler.FileService.StoreStackFileFromString(string(stack.ID), req.StackFileContent)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.StackManager.Deploy(stack, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handleGetStackFile handles GET requests on /:endpointId/stacks/:id/stackfile
func (handler *StackHandler) handleGetStackFile(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
stackID := vars["id"]
endpointID, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
_, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stack, err := handler.StackService.Stack(portainer.StackID(stackID))
if err == portainer.ErrStackNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint))
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
encodeJSON(w, &getStackFileResponse{StackFileContent: stackFileContent}, handler.Logger)
}
// handleDeleteStack handles DELETE requests on /:endpointId/stacks/:id
func (handler *StackHandler) handleDeleteStack(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
stackID := vars["id"]
endpointID, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stack, err := handler.StackService.Stack(portainer.StackID(stackID))
if err == portainer.ErrStackNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.StackManager.Remove(stack, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.StackService.DeleteStack(portainer.StackID(stackID))
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.FileService.RemoveDirectory(stack.ProjectPath)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}

View File

@ -30,7 +30,7 @@ func NewUploadHandler(bouncer *security.RequestBouncer) *UploadHandler {
return h return h
} }
// handlePostUploadTLS handles POST requests on /upload/tls/{certificate:(?:ca|cert|key)}?folder=folder // handlePostUploadTLS handles POST requests on /upload/tls/{certificate:(?:ca|cert|key)}?folder=<folder>
func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) { func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
certificate := vars["certificate"] certificate := vars["certificate"]

View File

@ -69,7 +69,7 @@ func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) {
host = endpointURL.Path host = endpointURL.Path
} }
// Should not be managed here // TODO: Should not be managed here
var tlsConfig *tls.Config var tlsConfig *tls.Config
if endpoint.TLSConfig.TLS { if endpoint.TLSConfig.TLS {
tlsConfig, err = crypto.CreateTLSConfiguration(&endpoint.TLSConfig) tlsConfig, err = crypto.CreateTLSConfiguration(&endpoint.TLSConfig)

View File

@ -2,6 +2,83 @@ package proxy
import "github.com/portainer/portainer" import "github.com/portainer/portainer"
type (
// ExtendedStack represents a stack combined with its associated access control
ExtendedStack struct {
portainer.Stack
ResourceControl portainer.ResourceControl `json:"ResourceControl"`
}
)
// applyResourceAccessControl returns an optionally decorated object as the first return value and the
// access level for the user (granted or denied) as the second return value.
// It will retrieve an identifier from the labels object. If an identifier exists, it will check for
// an existing resource control associated to it.
// Returns a decorated object and authorized access (true) when a resource control is found and the user can access the resource.
// Returns the original object and authorized access (true) when no resource control is found.
// Returns the original object and denied access (false) when a resource control is found and the user cannot access the resource.
func applyResourceAccessControlFromLabel(labelsObject, resourceObject map[string]interface{}, labelIdentifier string,
context *restrictedOperationContext) (map[string]interface{}, bool) {
if labelsObject != nil && labelsObject[labelIdentifier] != nil {
resourceIdentifier := labelsObject[labelIdentifier].(string)
return applyResourceAccessControl(resourceObject, resourceIdentifier, context)
}
return resourceObject, true
}
// applyResourceAccessControl returns an optionally decorated object as the first return value and the
// access level for the user (granted or denied) as the second return value.
// Returns a decorated object and authorized access (true) when a resource control is found to the specified resource
// identifier and the user can access the resource.
// Returns the original object and authorized access (true) when no resource control is found for the specified
// resource identifier.
// Returns the original object and denied access (false) when a resource control is associated to the resource
// and the user cannot access the resource.
func applyResourceAccessControl(resourceObject map[string]interface{}, resourceIdentifier string,
context *restrictedOperationContext) (map[string]interface{}, bool) {
authorizedAccess := true
resourceControl := getResourceControlByResourceID(resourceIdentifier, context.resourceControls)
if resourceControl != nil {
if context.isAdmin || canUserAccessResource(context.userID, context.userTeamIDs, resourceControl) {
resourceObject = decorateObject(resourceObject, resourceControl)
} else {
authorizedAccess = false
}
}
return resourceObject, authorizedAccess
}
// decorateResourceWithAccessControlFromLabel will retrieve an identifier from the labels object. If an identifier exists,
// it will check for an existing resource control associated to it. If a resource control is found, the resource object will be
// decorated. If no identifier can be found in the labels or no resource control is associated to the identifier, the resource
// object will not be changed.
func decorateResourceWithAccessControlFromLabel(labelsObject, resourceObject map[string]interface{}, labelIdentifier string,
resourceControls []portainer.ResourceControl) map[string]interface{} {
if labelsObject != nil && labelsObject[labelIdentifier] != nil {
resourceIdentifier := labelsObject[labelIdentifier].(string)
resourceObject = decorateResourceWithAccessControl(resourceObject, resourceIdentifier, resourceControls)
}
return resourceObject
}
// decorateResourceWithAccessControl will check if a resource control is associated to the specified resource identifier.
// If a resource control is found, the resource object will be decorated, otherwise it will not be changed.
func decorateResourceWithAccessControl(resourceObject map[string]interface{}, resourceIdentifier string,
resourceControls []portainer.ResourceControl) map[string]interface{} {
resourceControl := getResourceControlByResourceID(resourceIdentifier, resourceControls)
if resourceControl != nil {
return decorateObject(resourceObject, resourceControl)
}
return resourceObject
}
func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.TeamID, resourceControl *portainer.ResourceControl) bool { func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.TeamID, resourceControl *portainer.ResourceControl) bool {
for _, authorizedUserAccess := range resourceControl.UserAccesses { for _, authorizedUserAccess := range resourceControl.UserAccesses {
if userID == authorizedUserAccess.UserID { if userID == authorizedUserAccess.UserID {
@ -19,3 +96,63 @@ func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.Team
return false return false
} }
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
metadata := make(map[string]interface{})
metadata["ResourceControl"] = resourceControl
object["Portainer"] = metadata
return object
}
func getResourceControlByResourceID(resourceID string, resourceControls []portainer.ResourceControl) *portainer.ResourceControl {
for _, resourceControl := range resourceControls {
if resourceID == resourceControl.ResourceID {
return &resourceControl
}
for _, subResourceID := range resourceControl.SubResourceIDs {
if resourceID == subResourceID {
return &resourceControl
}
}
}
return nil
}
// CanAccessStack checks if a user can access a stack
func CanAccessStack(stack *portainer.Stack, resourceControl *portainer.ResourceControl, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
userTeamIDs := make([]portainer.TeamID, 0)
for _, membership := range memberships {
userTeamIDs = append(userTeamIDs, membership.TeamID)
}
if canUserAccessResource(userID, userTeamIDs, resourceControl) {
return true
}
return false
}
// FilterStacks filters stacks based on user role and resource controls.
func FilterStacks(stacks []portainer.Stack, resourceControls []portainer.ResourceControl, isAdmin bool,
userID portainer.UserID, memberships []portainer.TeamMembership) []ExtendedStack {
filteredStacks := make([]ExtendedStack, 0)
userTeamIDs := make([]portainer.TeamID, 0)
for _, membership := range memberships {
userTeamIDs = append(userTeamIDs, membership.TeamID)
}
for _, stack := range stacks {
extendedStack := ExtendedStack{stack, portainer.ResourceControl{}}
resourceControl := getResourceControlByResourceID(stack.Name, resourceControls)
if resourceControl == nil {
filteredStacks = append(filteredStacks, extendedStack)
} else if resourceControl != nil && (isAdmin || canUserAccessResource(userID, userTeamIDs, resourceControl)) {
extendedStack.ResourceControl = *resourceControl
filteredStacks = append(filteredStacks, extendedStack)
}
}
return filteredStacks
}

View File

@ -11,6 +11,7 @@ const (
ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found") ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found")
containerIdentifier = "Id" containerIdentifier = "Id"
containerLabelForServiceIdentifier = "com.docker.swarm.service.id" containerLabelForServiceIdentifier = "com.docker.swarm.service.id"
containerLabelForStackIdentifier = "com.docker.stack.namespace"
) )
// containerListOperation extracts the response as a JSON object, loop through the containers array // containerListOperation extracts the response as a JSON object, loop through the containers array
@ -27,8 +28,7 @@ func containerListOperation(request *http.Request, response *http.Response, exec
if executor.operationContext.isAdmin { if executor.operationContext.isAdmin {
responseArray, err = decorateContainerList(responseArray, executor.operationContext.resourceControls) responseArray, err = decorateContainerList(responseArray, executor.operationContext.resourceControls)
} else { } else {
responseArray, err = filterContainerList(responseArray, executor.operationContext.resourceControls, responseArray, err = filterContainerList(responseArray, executor.operationContext)
executor.operationContext.userID, executor.operationContext.userTeamIDs)
} }
if err != nil { if err != nil {
return err return err
@ -58,30 +58,22 @@ func containerInspectOperation(request *http.Request, response *http.Response, e
if responseObject[containerIdentifier] == nil { if responseObject[containerIdentifier] == nil {
return ErrDockerContainerIdentifierNotFound return ErrDockerContainerIdentifierNotFound
} }
containerID := responseObject[containerIdentifier].(string)
resourceControl := getResourceControlByResourceID(containerID, executor.operationContext.resourceControls) containerID := responseObject[containerIdentifier].(string)
if resourceControl != nil { responseObject, access := applyResourceAccessControl(responseObject, containerID, executor.operationContext)
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, if !access {
executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response) return rewriteAccessDeniedResponse(response)
} }
}
containerLabels := extractContainerLabelsFromContainerInspectObject(responseObject) containerLabels := extractContainerLabelsFromContainerInspectObject(responseObject)
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil { responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForServiceIdentifier, executor.operationContext)
serviceID := containerLabels[containerLabelForServiceIdentifier].(string) if !access {
resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls)
if resourceControl != nil {
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID,
executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response) return rewriteAccessDeniedResponse(response)
} }
}
responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForStackIdentifier, executor.operationContext)
if !access {
return rewriteAccessDeniedResponse(response)
} }
return rewriteResponse(response, responseObject, http.StatusOK) return rewriteResponse(response, responseObject, http.StatusOK)
@ -106,3 +98,96 @@ func extractContainerLabelsFromContainerListObject(responseObject map[string]int
containerLabelsObject := extractJSONField(responseObject, "Labels") containerLabelsObject := extractJSONField(responseObject, "Labels")
return containerLabelsObject return containerLabelsObject
} }
// decorateContainerList loops through all containers and decorates any container with an existing resource control.
// Resource controls checks are based on: resource identifier, service identifier (from label), stack identifier (from label).
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
func decorateContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
if containerObject[containerIdentifier] == nil {
return nil, ErrDockerContainerIdentifierNotFound
}
containerID := containerObject[containerIdentifier].(string)
containerObject = decorateResourceWithAccessControl(containerObject, containerID, resourceControls)
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, resourceControls)
containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForStackIdentifier, resourceControls)
decoratedContainerData = append(decoratedContainerData, containerObject)
}
return decoratedContainerData, nil
}
// filterContainerList loops through all containers and filters public containers (no associated resource control)
// as well as authorized containers (access granted to the user based on existing resource control).
// Authorized containers are decorated during the process.
// Resource controls checks are based on: resource identifier, service identifier (from label), stack identifier (from label).
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
func filterContainerList(containerData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
filteredContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
if containerObject[containerIdentifier] == nil {
return nil, ErrDockerContainerIdentifierNotFound
}
containerID := containerObject[containerIdentifier].(string)
containerObject, access := applyResourceAccessControl(containerObject, containerID, context)
if access {
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, context)
if access {
containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForStackIdentifier, context)
if access {
filteredContainerData = append(filteredContainerData, containerObject)
}
}
}
}
return filteredContainerData, nil
}
// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains
// any labels in the labels black list.
func filterContainersWithBlackListedLabels(containerData []interface{}, labelBlackList []portainer.Pair) ([]interface{}, error) {
filteredContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
if containerLabels != nil {
if !containerHasBlackListedLabel(containerLabels, labelBlackList) {
filteredContainerData = append(filteredContainerData, containerObject)
}
} else {
filteredContainerData = append(filteredContainerData, containerObject)
}
}
return filteredContainerData, nil
}
func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelBlackList []portainer.Pair) bool {
for key, value := range containerLabels {
labelName := key
labelValue := value.(string)
for _, blackListedLabel := range labelBlackList {
if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue {
return true
}
}
}
return false
}

View File

@ -1,138 +0,0 @@
package proxy
import "github.com/portainer/portainer"
// decorateVolumeList loops through all volumes and will decorate any volume with an existing resource control.
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
func decorateVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedVolumeData := make([]interface{}, 0)
for _, volume := range volumeData {
volumeObject := volume.(map[string]interface{})
if volumeObject[volumeIdentifier] == nil {
return nil, ErrDockerVolumeIdentifierNotFound
}
volumeID := volumeObject[volumeIdentifier].(string)
resourceControl := getResourceControlByResourceID(volumeID, resourceControls)
if resourceControl != nil {
volumeObject = decorateObject(volumeObject, resourceControl)
}
decoratedVolumeData = append(decoratedVolumeData, volumeObject)
}
return decoratedVolumeData, nil
}
// decorateContainerList loops through all containers and will decorate any container with an existing resource control.
// Check is based on the container ID and optional Swarm service ID.
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
func decorateContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
if containerObject[containerIdentifier] == nil {
return nil, ErrDockerContainerIdentifierNotFound
}
containerID := containerObject[containerIdentifier].(string)
resourceControl := getResourceControlByResourceID(containerID, resourceControls)
if resourceControl != nil {
containerObject = decorateObject(containerObject, resourceControl)
}
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil {
serviceID := containerLabels[containerLabelForServiceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
if resourceControl != nil {
containerObject = decorateObject(containerObject, resourceControl)
}
}
decoratedContainerData = append(decoratedContainerData, containerObject)
}
return decoratedContainerData, nil
}
// decorateServiceList loops through all services and will decorate any service with an existing resource control.
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
func decorateServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedServiceData := make([]interface{}, 0)
for _, service := range serviceData {
serviceObject := service.(map[string]interface{})
if serviceObject[serviceIdentifier] == nil {
return nil, ErrDockerServiceIdentifierNotFound
}
serviceID := serviceObject[serviceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
if resourceControl != nil {
serviceObject = decorateObject(serviceObject, resourceControl)
}
decoratedServiceData = append(decoratedServiceData, serviceObject)
}
return decoratedServiceData, nil
}
// decorateNetworkList loops through all networks and will decorate any network with an existing resource control.
// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
func decorateNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedNetworkData := make([]interface{}, 0)
for _, network := range networkData {
networkObject := network.(map[string]interface{})
if networkObject[networkIdentifier] == nil {
return nil, ErrDockerNetworkIdentifierNotFound
}
networkID := networkObject[networkIdentifier].(string)
resourceControl := getResourceControlByResourceID(networkID, resourceControls)
if resourceControl != nil {
networkObject = decorateObject(networkObject, resourceControl)
}
decoratedNetworkData = append(decoratedNetworkData, networkObject)
}
return decoratedNetworkData, nil
}
// decorateSecretList loops through all secrets and will decorate any secret with an existing resource control.
// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList
func decorateSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedSecretData := make([]interface{}, 0)
for _, secret := range secretData {
secretObject := secret.(map[string]interface{})
if secretObject[secretIdentifier] == nil {
return nil, ErrDockerSecretIdentifierNotFound
}
secretID := secretObject[secretIdentifier].(string)
resourceControl := getResourceControlByResourceID(secretID, resourceControls)
if resourceControl != nil {
secretObject = decorateObject(secretObject, resourceControl)
}
decoratedSecretData = append(decoratedSecretData, secretObject)
}
return decoratedSecretData, nil
}
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
metadata := make(map[string]interface{})
metadata["ResourceControl"] = resourceControl
object["Portainer"] = metadata
return object
}

View File

@ -1,6 +1,7 @@
package proxy package proxy
import ( import (
"net"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
@ -56,3 +57,15 @@ func (factory *proxyFactory) createReverseProxy(u *url.URL) *httputil.ReversePro
proxy.Transport = transport proxy.Transport = transport
return proxy return proxy
} }
func newSocketTransport(socketPath string) *http.Transport {
return &http.Transport{
Dial: func(proto, addr string) (conn net.Conn, err error) {
return net.Dial("unix", socketPath)
},
}
}
func newHTTPTransport() *http.Transport {
return &http.Transport{}
}

View File

@ -1,185 +0,0 @@
package proxy
import "github.com/portainer/portainer"
// filterVolumeList loops through all volumes, filters volumes without any resource control (public resources) or with
// any resource control giving access to the user (these volumes will be decorated).
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
func filterVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredVolumeData := make([]interface{}, 0)
for _, volume := range volumeData {
volumeObject := volume.(map[string]interface{})
if volumeObject[volumeIdentifier] == nil {
return nil, ErrDockerVolumeIdentifierNotFound
}
volumeID := volumeObject[volumeIdentifier].(string)
resourceControl := getResourceControlByResourceID(volumeID, resourceControls)
if resourceControl == nil {
filteredVolumeData = append(filteredVolumeData, volumeObject)
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
volumeObject = decorateObject(volumeObject, resourceControl)
filteredVolumeData = append(filteredVolumeData, volumeObject)
}
}
return filteredVolumeData, nil
}
// filterContainerList loops through all containers, filters containers without any resource control (public resources) or with
// any resource control giving access to the user (check on container ID and optional Swarm service ID, these containers will be decorated).
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
func filterContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
if containerObject[containerIdentifier] == nil {
return nil, ErrDockerContainerIdentifierNotFound
}
containerID := containerObject[containerIdentifier].(string)
resourceControl := getResourceControlByResourceID(containerID, resourceControls)
if resourceControl == nil {
// check if container is part of a Swarm service
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil {
serviceID := containerLabels[containerLabelForServiceIdentifier].(string)
serviceResourceControl := getResourceControlByResourceID(serviceID, resourceControls)
if serviceResourceControl == nil {
filteredContainerData = append(filteredContainerData, containerObject)
} else if serviceResourceControl != nil && canUserAccessResource(userID, userTeamIDs, serviceResourceControl) {
containerObject = decorateObject(containerObject, serviceResourceControl)
filteredContainerData = append(filteredContainerData, containerObject)
}
} else {
filteredContainerData = append(filteredContainerData, containerObject)
}
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
containerObject = decorateObject(containerObject, resourceControl)
filteredContainerData = append(filteredContainerData, containerObject)
}
}
return filteredContainerData, nil
}
// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains
// any labels in the labels black list.
func filterContainersWithBlackListedLabels(containerData []interface{}, labelBlackList []portainer.Pair) ([]interface{}, error) {
filteredContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
if containerLabels != nil {
if !containerHasBlackListedLabel(containerLabels, labelBlackList) {
filteredContainerData = append(filteredContainerData, containerObject)
}
} else {
filteredContainerData = append(filteredContainerData, containerObject)
}
}
return filteredContainerData, nil
}
// filterServiceList loops through all services, filters services without any resource control (public resources) or with
// any resource control giving access to the user (these services will be decorated).
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
func filterServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredServiceData := make([]interface{}, 0)
for _, service := range serviceData {
serviceObject := service.(map[string]interface{})
if serviceObject[serviceIdentifier] == nil {
return nil, ErrDockerServiceIdentifierNotFound
}
serviceID := serviceObject[serviceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
if resourceControl == nil {
filteredServiceData = append(filteredServiceData, serviceObject)
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
serviceObject = decorateObject(serviceObject, resourceControl)
filteredServiceData = append(filteredServiceData, serviceObject)
}
}
return filteredServiceData, nil
}
// filterNetworkList loops through all networks, filters networks without any resource control (public resources) or with
// any resource control giving access to the user (these networks will be decorated).
// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
func filterNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredNetworkData := make([]interface{}, 0)
for _, network := range networkData {
networkObject := network.(map[string]interface{})
if networkObject[networkIdentifier] == nil {
return nil, ErrDockerNetworkIdentifierNotFound
}
networkID := networkObject[networkIdentifier].(string)
resourceControl := getResourceControlByResourceID(networkID, resourceControls)
if resourceControl == nil {
filteredNetworkData = append(filteredNetworkData, networkObject)
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
networkObject = decorateObject(networkObject, resourceControl)
filteredNetworkData = append(filteredNetworkData, networkObject)
}
}
return filteredNetworkData, nil
}
// filterSecretList loops through all secrets, filters secrets without any resource control (public resources) or with
// any resource control giving access to the user (these secrets will be decorated).
// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList
func filterSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredSecretData := make([]interface{}, 0)
for _, secret := range secretData {
secretObject := secret.(map[string]interface{})
if secretObject[secretIdentifier] == nil {
return nil, ErrDockerSecretIdentifierNotFound
}
secretID := secretObject[secretIdentifier].(string)
resourceControl := getResourceControlByResourceID(secretID, resourceControls)
if resourceControl == nil {
filteredSecretData = append(filteredSecretData, secretObject)
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
secretObject = decorateObject(secretObject, resourceControl)
filteredSecretData = append(filteredSecretData, secretObject)
}
}
return filteredSecretData, nil
}
// filterTaskList loops through all tasks, filters tasks without any resource control (public resources) or with
// any resource control giving access to the user based on the associated service identifier.
// Task object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList
func filterTaskList(taskData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredTaskData := make([]interface{}, 0)
for _, task := range taskData {
taskObject := task.(map[string]interface{})
if taskObject[taskServiceIdentifier] == nil {
return nil, ErrDockerTaskServiceIdentifierNotFound
}
serviceID := taskObject[taskServiceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
if resourceControl == nil || (resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl)) {
filteredTaskData = append(filteredTaskData, taskObject)
}
}
return filteredTaskData, nil
}

View File

@ -10,6 +10,7 @@ const (
// ErrDockerNetworkIdentifierNotFound defines an error raised when Portainer is unable to find a network identifier // ErrDockerNetworkIdentifierNotFound defines an error raised when Portainer is unable to find a network identifier
ErrDockerNetworkIdentifierNotFound = portainer.Error("Docker network identifier not found") ErrDockerNetworkIdentifierNotFound = portainer.Error("Docker network identifier not found")
networkIdentifier = "Id" networkIdentifier = "Id"
networkLabelForStackIdentifier = "com.docker.stack.namespace"
) )
// networkListOperation extracts the response as a JSON object, loop through the networks array // networkListOperation extracts the response as a JSON object, loop through the networks array
@ -26,8 +27,7 @@ func networkListOperation(request *http.Request, response *http.Response, execut
if executor.operationContext.isAdmin { if executor.operationContext.isAdmin {
responseArray, err = decorateNetworkList(responseArray, executor.operationContext.resourceControls) responseArray, err = decorateNetworkList(responseArray, executor.operationContext.resourceControls)
} else { } else {
responseArray, err = filterNetworkList(responseArray, executor.operationContext.resourceControls, responseArray, err = filterNetworkList(responseArray, executor.operationContext)
executor.operationContext.userID, executor.operationContext.userTeamIDs)
} }
if err != nil { if err != nil {
return err return err
@ -50,17 +50,85 @@ func networkInspectOperation(request *http.Request, response *http.Response, exe
if responseObject[networkIdentifier] == nil { if responseObject[networkIdentifier] == nil {
return ErrDockerNetworkIdentifierNotFound return ErrDockerNetworkIdentifierNotFound
} }
networkID := responseObject[networkIdentifier].(string)
resourceControl := getResourceControlByResourceID(networkID, executor.operationContext.resourceControls) networkID := responseObject[networkIdentifier].(string)
if resourceControl != nil { responseObject, access := applyResourceAccessControl(responseObject, networkID, executor.operationContext)
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, if !access {
executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response) return rewriteAccessDeniedResponse(response)
} }
networkLabels := extractNetworkLabelsFromNetworkInspectObject(responseObject)
responseObject, access = applyResourceAccessControlFromLabel(networkLabels, responseObject, networkLabelForStackIdentifier, executor.operationContext)
if !access {
return rewriteAccessDeniedResponse(response)
} }
return rewriteResponse(response, responseObject, http.StatusOK) return rewriteResponse(response, responseObject, http.StatusOK)
} }
// extractNetworkLabelsFromNetworkInspectObject retrieve the Labels of the network if present.
// Container schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
func extractNetworkLabelsFromNetworkInspectObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Labels
return extractJSONField(responseObject, "Labels")
}
// extractNetworkLabelsFromNetworkListObject retrieve the Labels of the network if present.
// Network schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
func extractNetworkLabelsFromNetworkListObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Labels
return extractJSONField(responseObject, "Labels")
}
// decorateNetworkList loops through all networks and decorates any network with an existing resource control.
// Resource controls checks are based on: resource identifier, stack identifier (from label).
// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
func decorateNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedNetworkData := make([]interface{}, 0)
for _, network := range networkData {
networkObject := network.(map[string]interface{})
if networkObject[networkIdentifier] == nil {
return nil, ErrDockerNetworkIdentifierNotFound
}
networkID := networkObject[networkIdentifier].(string)
networkObject = decorateResourceWithAccessControl(networkObject, networkID, resourceControls)
networkLabels := extractNetworkLabelsFromNetworkListObject(networkObject)
networkObject = decorateResourceWithAccessControlFromLabel(networkLabels, networkObject, networkLabelForStackIdentifier, resourceControls)
decoratedNetworkData = append(decoratedNetworkData, networkObject)
}
return decoratedNetworkData, nil
}
// filterNetworkList loops through all networks and filters public networks (no associated resource control)
// as well as authorized networks (access granted to the user based on existing resource control).
// Authorized networks are decorated during the process.
// Resource controls checks are based on: resource identifier, stack identifier (from label).
// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
func filterNetworkList(networkData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
filteredNetworkData := make([]interface{}, 0)
for _, network := range networkData {
networkObject := network.(map[string]interface{})
if networkObject[networkIdentifier] == nil {
return nil, ErrDockerNetworkIdentifierNotFound
}
networkID := networkObject[networkIdentifier].(string)
networkObject, access := applyResourceAccessControl(networkObject, networkID, context)
if access {
networkLabels := extractNetworkLabelsFromNetworkListObject(networkObject)
networkObject, access = applyResourceAccessControlFromLabel(networkLabels, networkObject, networkLabelForStackIdentifier, context)
if access {
filteredNetworkData = append(filteredNetworkData, networkObject)
}
}
}
return filteredNetworkData, nil
}

View File

@ -27,8 +27,7 @@ func secretListOperation(request *http.Request, response *http.Response, executo
if executor.operationContext.isAdmin { if executor.operationContext.isAdmin {
responseArray, err = decorateSecretList(responseArray, executor.operationContext.resourceControls) responseArray, err = decorateSecretList(responseArray, executor.operationContext.resourceControls)
} else { } else {
responseArray, err = filterSecretList(responseArray, executor.operationContext.resourceControls, responseArray, err = filterSecretList(responseArray, executor.operationContext)
executor.operationContext.userID, executor.operationContext.userTeamIDs)
} }
if err != nil { if err != nil {
return err return err
@ -51,17 +50,58 @@ func secretInspectOperation(request *http.Request, response *http.Response, exec
if responseObject[secretIdentifier] == nil { if responseObject[secretIdentifier] == nil {
return ErrDockerSecretIdentifierNotFound return ErrDockerSecretIdentifierNotFound
} }
secretID := responseObject[secretIdentifier].(string)
resourceControl := getResourceControlByResourceID(secretID, executor.operationContext.resourceControls) secretID := responseObject[secretIdentifier].(string)
if resourceControl != nil { responseObject, access := applyResourceAccessControl(responseObject, secretID, executor.operationContext)
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, if !access {
executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response) return rewriteAccessDeniedResponse(response)
} }
}
return rewriteResponse(response, responseObject, http.StatusOK) return rewriteResponse(response, responseObject, http.StatusOK)
} }
// decorateSecretList loops through all secrets and decorates any secret with an existing resource control.
// Resource controls checks are based on: resource identifier.
// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList
func decorateSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedSecretData := make([]interface{}, 0)
for _, secret := range secretData {
secretObject := secret.(map[string]interface{})
if secretObject[secretIdentifier] == nil {
return nil, ErrDockerSecretIdentifierNotFound
}
secretID := secretObject[secretIdentifier].(string)
secretObject = decorateResourceWithAccessControl(secretObject, secretID, resourceControls)
decoratedSecretData = append(decoratedSecretData, secretObject)
}
return decoratedSecretData, nil
}
// filterSecretList loops through all secrets and filters public secrets (no associated resource control)
// as well as authorized secrets (access granted to the user based on existing resource control).
// Authorized secrets are decorated during the process.
// Resource controls checks are based on: resource identifier.
// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList
func filterSecretList(secretData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
filteredSecretData := make([]interface{}, 0)
for _, secret := range secretData {
secretObject := secret.(map[string]interface{})
if secretObject[secretIdentifier] == nil {
return nil, ErrDockerSecretIdentifierNotFound
}
secretID := secretObject[secretIdentifier].(string)
secretObject, access := applyResourceAccessControl(secretObject, secretID, context)
if access {
filteredSecretData = append(filteredSecretData, secretObject)
}
}
return filteredSecretData, nil
}

View File

@ -1,64 +0,0 @@
package proxy
import (
"net/http"
"github.com/portainer/portainer"
)
const (
// ErrDockerServiceIdentifierNotFound defines an error raised when Portainer is unable to find a service identifier
ErrDockerServiceIdentifierNotFound = portainer.Error("Docker service identifier not found")
serviceIdentifier = "ID"
)
// serviceListOperation extracts the response as a JSON array, loop through the service array
// decorate and/or filter the services based on resource controls before rewriting the response
func serviceListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
var err error
// ServiceList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
responseArray, err := getResponseAsJSONArray(response)
if err != nil {
return err
}
if executor.operationContext.isAdmin {
responseArray, err = decorateServiceList(responseArray, executor.operationContext.resourceControls)
} else {
responseArray, err = filterServiceList(responseArray, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs)
}
if err != nil {
return err
}
return rewriteResponse(response, responseArray, http.StatusOK)
}
// serviceInspectOperation extracts the response as a JSON object, verify that the user
// has access to the service based on resource control and either rewrite an access denied response
// or a decorated service.
func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
// ServiceInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
responseObject, err := getResponseAsJSONOBject(response)
if err != nil {
return err
}
if responseObject[serviceIdentifier] == nil {
return ErrDockerServiceIdentifierNotFound
}
serviceID := responseObject[serviceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls)
if resourceControl != nil {
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response)
}
}
return rewriteResponse(response, responseObject, http.StatusOK)
}

142
api/http/proxy/services.go Normal file
View File

@ -0,0 +1,142 @@
package proxy
import (
"net/http"
"github.com/portainer/portainer"
)
const (
// ErrDockerServiceIdentifierNotFound defines an error raised when Portainer is unable to find a service identifier
ErrDockerServiceIdentifierNotFound = portainer.Error("Docker service identifier not found")
serviceIdentifier = "ID"
serviceLabelForStackIdentifier = "com.docker.stack.namespace"
)
// serviceListOperation extracts the response as a JSON array, loop through the service array
// decorate and/or filter the services based on resource controls before rewriting the response
func serviceListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
var err error
// ServiceList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
responseArray, err := getResponseAsJSONArray(response)
if err != nil {
return err
}
if executor.operationContext.isAdmin {
responseArray, err = decorateServiceList(responseArray, executor.operationContext.resourceControls)
} else {
responseArray, err = filterServiceList(responseArray, executor.operationContext)
}
if err != nil {
return err
}
return rewriteResponse(response, responseArray, http.StatusOK)
}
// serviceInspectOperation extracts the response as a JSON object, verify that the user
// has access to the service based on resource control and either rewrite an access denied response
// or a decorated service.
func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
// ServiceInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
responseObject, err := getResponseAsJSONOBject(response)
if err != nil {
return err
}
if responseObject[serviceIdentifier] == nil {
return ErrDockerServiceIdentifierNotFound
}
serviceID := responseObject[serviceIdentifier].(string)
responseObject, access := applyResourceAccessControl(responseObject, serviceID, executor.operationContext)
if !access {
return rewriteAccessDeniedResponse(response)
}
serviceLabels := extractServiceLabelsFromServiceInspectObject(responseObject)
responseObject, access = applyResourceAccessControlFromLabel(serviceLabels, responseObject, serviceLabelForStackIdentifier, executor.operationContext)
if !access {
return rewriteAccessDeniedResponse(response)
}
return rewriteResponse(response, responseObject, http.StatusOK)
}
// extractServiceLabelsFromServiceInspectObject retrieve the Labels of the service if present.
// Service schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
func extractServiceLabelsFromServiceInspectObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Spec.Labels
serviceSpecObject := extractJSONField(responseObject, "Spec")
if serviceSpecObject != nil {
return extractJSONField(serviceSpecObject, "Labels")
}
return nil
}
// extractServiceLabelsFromServiceListObject retrieve the Labels of the service if present.
// Service schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
func extractServiceLabelsFromServiceListObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Spec.Labels
serviceSpecObject := extractJSONField(responseObject, "Spec")
if serviceSpecObject != nil {
return extractJSONField(serviceSpecObject, "Labels")
}
return nil
}
// decorateServiceList loops through all services and decorates any service with an existing resource control.
// Resource controls checks are based on: resource identifier, stack identifier (from label).
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
func decorateServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedServiceData := make([]interface{}, 0)
for _, service := range serviceData {
serviceObject := service.(map[string]interface{})
if serviceObject[serviceIdentifier] == nil {
return nil, ErrDockerServiceIdentifierNotFound
}
serviceID := serviceObject[serviceIdentifier].(string)
serviceObject = decorateResourceWithAccessControl(serviceObject, serviceID, resourceControls)
serviceLabels := extractServiceLabelsFromServiceListObject(serviceObject)
serviceObject = decorateResourceWithAccessControlFromLabel(serviceLabels, serviceObject, serviceLabelForStackIdentifier, resourceControls)
decoratedServiceData = append(decoratedServiceData, serviceObject)
}
return decoratedServiceData, nil
}
// filterServiceList loops through all services and filters public services (no associated resource control)
// as well as authorized services (access granted to the user based on existing resource control).
// Authorized services are decorated during the process.
// Resource controls checks are based on: resource identifier, stack identifier (from label).
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
func filterServiceList(serviceData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
filteredServiceData := make([]interface{}, 0)
for _, service := range serviceData {
serviceObject := service.(map[string]interface{})
if serviceObject[serviceIdentifier] == nil {
return nil, ErrDockerServiceIdentifierNotFound
}
serviceID := serviceObject[serviceIdentifier].(string)
serviceObject, access := applyResourceAccessControl(serviceObject, serviceID, context)
if access {
serviceLabels := extractServiceLabelsFromServiceListObject(serviceObject)
serviceObject, access = applyResourceAccessControlFromLabel(serviceLabels, serviceObject, serviceLabelForStackIdentifier, context)
if access {
filteredServiceData = append(filteredServiceData, serviceObject)
}
}
}
return filteredServiceData, nil
}

View File

@ -10,6 +10,7 @@ const (
// ErrDockerTaskServiceIdentifierNotFound defines an error raised when Portainer is unable to find the service identifier associated to a task // ErrDockerTaskServiceIdentifierNotFound defines an error raised when Portainer is unable to find the service identifier associated to a task
ErrDockerTaskServiceIdentifierNotFound = portainer.Error("Docker task service identifier not found") ErrDockerTaskServiceIdentifierNotFound = portainer.Error("Docker task service identifier not found")
taskServiceIdentifier = "ServiceID" taskServiceIdentifier = "ServiceID"
taskLabelForStackIdentifier = "com.docker.stack.namespace"
) )
// taskListOperation extracts the response as a JSON object, loop through the tasks array // taskListOperation extracts the response as a JSON object, loop through the tasks array
@ -25,8 +26,7 @@ func taskListOperation(request *http.Request, response *http.Response, executor
} }
if !executor.operationContext.isAdmin { if !executor.operationContext.isAdmin {
responseArray, err = filterTaskList(responseArray, executor.operationContext.resourceControls, responseArray, err = filterTaskList(responseArray, executor.operationContext)
executor.operationContext.userID, executor.operationContext.userTeamIDs)
if err != nil { if err != nil {
return err return err
} }
@ -34,3 +34,45 @@ func taskListOperation(request *http.Request, response *http.Response, executor
return rewriteResponse(response, responseArray, http.StatusOK) return rewriteResponse(response, responseArray, http.StatusOK)
} }
// extractTaskLabelsFromTaskListObject retrieve the Labels of the task if present.
// Task schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList
func extractTaskLabelsFromTaskListObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Spec.ContainerSpec.Labels
taskSpecObject := extractJSONField(responseObject, "Spec")
if taskSpecObject != nil {
containerSpecObject := extractJSONField(taskSpecObject, "ContainerSpec")
if containerSpecObject != nil {
return extractJSONField(containerSpecObject, "Labels")
}
}
return nil
}
// filterTaskList loops through all tasks and filters public tasks (no associated resource control)
// as well as authorized tasks (access granted to the user based on existing resource control).
// Resource controls checks are based on: service identifier, stack identifier (from label).
// Task object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList
// any resource control giving access to the user based on the associated service identifier.
func filterTaskList(taskData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
filteredTaskData := make([]interface{}, 0)
for _, task := range taskData {
taskObject := task.(map[string]interface{})
if taskObject[taskServiceIdentifier] == nil {
return nil, ErrDockerTaskServiceIdentifierNotFound
}
serviceID := taskObject[taskServiceIdentifier].(string)
taskObject, access := applyResourceAccessControl(taskObject, serviceID, context)
if access {
taskLabels := extractTaskLabelsFromTaskListObject(taskObject)
taskObject, access = applyResourceAccessControlFromLabel(taskLabels, taskObject, taskLabelForStackIdentifier, context)
if access {
filteredTaskData = append(filteredTaskData, taskObject)
}
}
}
return filteredTaskData, nil
}

View File

@ -1,7 +1,6 @@
package proxy package proxy
import ( import (
"net"
"net/http" "net/http"
"path" "path"
"strings" "strings"
@ -30,18 +29,6 @@ type (
restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error
) )
func newSocketTransport(socketPath string) *http.Transport {
return &http.Transport{
Dial: func(proto, addr string) (conn net.Conn, err error) {
return net.Dial("unix", socketPath)
},
}
}
func newHTTPTransport() *http.Transport {
return &http.Transport{}
}
func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) { func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) {
return p.proxyDockerRequest(request) return p.proxyDockerRequest(request)
} }
@ -202,7 +189,13 @@ func (p *proxyTransport) proxyNodeRequest(request *http.Request) (*http.Response
} }
func (p *proxyTransport) proxySwarmRequest(request *http.Request) (*http.Response, error) { func (p *proxyTransport) proxySwarmRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/swarm":
return p.executeDockerRequest(request)
default:
// assume /swarm/{action}
return p.administratorOperation(request) return p.administratorOperation(request)
}
} }
func (p *proxyTransport) proxyTaskRequest(request *http.Request) (*http.Response, error) { func (p *proxyTransport) proxyTaskRequest(request *http.Request) (*http.Response, error) {

View File

@ -1,32 +0,0 @@
package proxy
import "github.com/portainer/portainer"
func getResourceControlByResourceID(resourceID string, resourceControls []portainer.ResourceControl) *portainer.ResourceControl {
for _, resourceControl := range resourceControls {
if resourceID == resourceControl.ResourceID {
return &resourceControl
}
for _, subResourceID := range resourceControl.SubResourceIDs {
if resourceID == subResourceID {
return &resourceControl
}
}
}
return nil
}
func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelBlackList []portainer.Pair) bool {
for key, value := range containerLabels {
labelName := key
labelValue := value.(string)
for _, blackListedLabel := range labelBlackList {
if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue {
return true
}
}
}
return false
}

View File

@ -10,6 +10,7 @@ const (
// ErrDockerVolumeIdentifierNotFound defines an error raised when Portainer is unable to find a volume identifier // ErrDockerVolumeIdentifierNotFound defines an error raised when Portainer is unable to find a volume identifier
ErrDockerVolumeIdentifierNotFound = portainer.Error("Docker volume identifier not found") ErrDockerVolumeIdentifierNotFound = portainer.Error("Docker volume identifier not found")
volumeIdentifier = "Name" volumeIdentifier = "Name"
volumeLabelForStackIdentifier = "com.docker.stack.namespace"
) )
// volumeListOperation extracts the response as a JSON object, loop through the volume array // volumeListOperation extracts the response as a JSON object, loop through the volume array
@ -31,7 +32,7 @@ func volumeListOperation(request *http.Request, response *http.Response, executo
if executor.operationContext.isAdmin { if executor.operationContext.isAdmin {
volumeData, err = decorateVolumeList(volumeData, executor.operationContext.resourceControls) volumeData, err = decorateVolumeList(volumeData, executor.operationContext.resourceControls)
} else { } else {
volumeData, err = filterVolumeList(volumeData, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs) volumeData, err = filterVolumeList(volumeData, executor.operationContext)
} }
if err != nil { if err != nil {
return err return err
@ -45,7 +46,7 @@ func volumeListOperation(request *http.Request, response *http.Response, executo
} }
// volumeInspectOperation extracts the response as a JSON object, verify that the user // volumeInspectOperation extracts the response as a JSON object, verify that the user
// has access to the volume based on resource control and either rewrite an access denied response // has access to the volume based on any existing resource control and either rewrite an access denied response
// or a decorated volume. // or a decorated volume.
func volumeInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { func volumeInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
// VolumeInspect response is a JSON object // VolumeInspect response is a JSON object
@ -58,16 +59,85 @@ func volumeInspectOperation(request *http.Request, response *http.Response, exec
if responseObject[volumeIdentifier] == nil { if responseObject[volumeIdentifier] == nil {
return ErrDockerVolumeIdentifierNotFound return ErrDockerVolumeIdentifierNotFound
} }
volumeID := responseObject[volumeIdentifier].(string)
resourceControl := getResourceControlByResourceID(volumeID, executor.operationContext.resourceControls) volumeID := responseObject[volumeIdentifier].(string)
if resourceControl != nil { responseObject, access := applyResourceAccessControl(responseObject, volumeID, executor.operationContext)
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) { if !access {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response) return rewriteAccessDeniedResponse(response)
} }
volumeLabels := extractVolumeLabelsFromVolumeInspectObject(responseObject)
responseObject, access = applyResourceAccessControlFromLabel(volumeLabels, responseObject, volumeLabelForStackIdentifier, executor.operationContext)
if !access {
return rewriteAccessDeniedResponse(response)
} }
return rewriteResponse(response, responseObject, http.StatusOK) return rewriteResponse(response, responseObject, http.StatusOK)
} }
// extractVolumeLabelsFromVolumeInspectObject retrieve the Labels of the volume if present.
// Volume schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
func extractVolumeLabelsFromVolumeInspectObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Labels
return extractJSONField(responseObject, "Labels")
}
// extractVolumeLabelsFromVolumeListObject retrieve the Labels of the volume if present.
// Volume schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
func extractVolumeLabelsFromVolumeListObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Labels
return extractJSONField(responseObject, "Labels")
}
// decorateVolumeList loops through all volumes and decorates any volume with an existing resource control.
// Resource controls checks are based on: resource identifier, stack identifier (from label).
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
func decorateVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedVolumeData := make([]interface{}, 0)
for _, volume := range volumeData {
volumeObject := volume.(map[string]interface{})
if volumeObject[volumeIdentifier] == nil {
return nil, ErrDockerVolumeIdentifierNotFound
}
volumeID := volumeObject[volumeIdentifier].(string)
volumeObject = decorateResourceWithAccessControl(volumeObject, volumeID, resourceControls)
volumeLabels := extractVolumeLabelsFromVolumeListObject(volumeObject)
volumeObject = decorateResourceWithAccessControlFromLabel(volumeLabels, volumeObject, volumeLabelForStackIdentifier, resourceControls)
decoratedVolumeData = append(decoratedVolumeData, volumeObject)
}
return decoratedVolumeData, nil
}
// filterVolumeList loops through all volumes and filters public volumes (no associated resource control)
// as well as authorized volumes (access granted to the user based on existing resource control).
// Authorized volumes are decorated during the process.
// Resource controls checks are based on: resource identifier, stack identifier (from label).
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
func filterVolumeList(volumeData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
filteredVolumeData := make([]interface{}, 0)
for _, volume := range volumeData {
volumeObject := volume.(map[string]interface{})
if volumeObject[volumeIdentifier] == nil {
return nil, ErrDockerVolumeIdentifierNotFound
}
volumeID := volumeObject[volumeIdentifier].(string)
volumeObject, access := applyResourceAccessControl(volumeObject, volumeID, context)
if access {
volumeLabels := extractVolumeLabelsFromVolumeListObject(volumeObject)
volumeObject, access = applyResourceAccessControlFromLabel(volumeLabels, volumeObject, volumeLabelForStackIdentifier, context)
if access {
filteredVolumeData = append(filteredVolumeData, volumeObject)
}
}
}
return filteredVolumeData, nil
}

View File

@ -27,7 +27,10 @@ type Server struct {
FileService portainer.FileService FileService portainer.FileService
RegistryService portainer.RegistryService RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService DockerHubService portainer.DockerHubService
StackService portainer.StackService
StackManager portainer.StackManager
LDAPService portainer.LDAPService LDAPService portainer.LDAPService
GitService portainer.GitService
Handler *handler.Handler Handler *handler.Handler
SSL bool SSL bool
SSLCert string SSLCert string
@ -39,6 +42,7 @@ func (server *Server) Start() error {
requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled) requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled)
proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService) proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService)
var fileHandler = handler.NewFileHandler(server.AssetsPath)
var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled) var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled)
authHandler.UserService = server.UserService authHandler.UserService = server.UserService
authHandler.CryptoService = server.CryptoService authHandler.CryptoService = server.CryptoService
@ -82,7 +86,13 @@ func (server *Server) Start() error {
resourceHandler.ResourceControlService = server.ResourceControlService resourceHandler.ResourceControlService = server.ResourceControlService
var uploadHandler = handler.NewUploadHandler(requestBouncer) var uploadHandler = handler.NewUploadHandler(requestBouncer)
uploadHandler.FileService = server.FileService uploadHandler.FileService = server.FileService
var fileHandler = handler.NewFileHandler(server.AssetsPath) var stackHandler = handler.NewStackHandler(requestBouncer)
stackHandler.FileService = server.FileService
stackHandler.StackService = server.StackService
stackHandler.EndpointService = server.EndpointService
stackHandler.ResourceControlService = server.ResourceControlService
stackHandler.StackManager = server.StackManager
stackHandler.GitService = server.GitService
server.Handler = &handler.Handler{ server.Handler = &handler.Handler{
AuthHandler: authHandler, AuthHandler: authHandler,
@ -95,6 +105,7 @@ func (server *Server) Start() error {
ResourceHandler: resourceHandler, ResourceHandler: resourceHandler,
SettingsHandler: settingsHandler, SettingsHandler: settingsHandler,
StatusHandler: statusHandler, StatusHandler: statusHandler,
StackHandler: stackHandler,
TemplatesHandler: templatesHandler, TemplatesHandler: templatesHandler,
DockerHandler: dockerHandler, DockerHandler: dockerHandler,
WebSocketHandler: websocketHandler, WebSocketHandler: websocketHandler,

View File

@ -33,7 +33,10 @@ func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearc
// Deliberately skip errors on the search request so that we can jump to other search settings // Deliberately skip errors on the search request so that we can jump to other search settings
// if any issue arise with the current one. // if any issue arise with the current one.
sr, _ := conn.Search(searchRequest) sr, err := conn.Search(searchRequest)
if err != nil {
continue
}
if len(sr.Entries) == 1 { if len(sr.Entries) == 1 {
found = true found = true

View File

@ -27,6 +27,7 @@ type (
SSLCert *string SSLCert *string
SSLKey *string SSLKey *string
AdminPassword *string AdminPassword *string
AdminPasswordFile *string
// Deprecated fields // Deprecated fields
Logo *string Logo *string
Templates *string Templates *string
@ -75,6 +76,8 @@ type (
DisplayExternalContributors bool `json:"DisplayExternalContributors"` DisplayExternalContributors bool `json:"DisplayExternalContributors"`
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"` AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
LDAPSettings LDAPSettings `json:"LDAPSettings"` LDAPSettings LDAPSettings `json:"LDAPSettings"`
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
} }
// User represents a user account. // User represents a user account.
@ -125,6 +128,18 @@ type (
Role UserRole Role UserRole
} }
// StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier).
StackID string
// Stack represents a Docker stack created via docker stack deploy.
Stack struct {
ID StackID `json:"Id"`
Name string `json:"Name"`
EntryPoint string `json:"EntryPoint"`
SwarmID string `json:"SwarmId"`
ProjectPath string
}
// RegistryID represents a registry identifier. // RegistryID represents a registry identifier.
RegistryID int RegistryID int
@ -190,7 +205,7 @@ type (
AccessLevel ResourceAccessLevel `json:"AccessLevel,omitempty"` AccessLevel ResourceAccessLevel `json:"AccessLevel,omitempty"`
} }
// ResourceControlType represents the type of resource associated to the resource control (volume, container, service). // ResourceControlType represents the type of resource associated to the resource control (volume, container, service...).
ResourceControlType int ResourceControlType int
// UserResourceAccess represents the level of control on a resource for a specific user. // UserResourceAccess represents the level of control on a resource for a specific user.
@ -283,6 +298,16 @@ type (
DeleteRegistry(ID RegistryID) error DeleteRegistry(ID RegistryID) error
} }
// StackService represents a service for managing stack data.
StackService interface {
Stack(ID StackID) (*Stack, error)
Stacks() ([]Stack, error)
StacksBySwarmID(ID string) ([]Stack, error)
CreateStack(stack *Stack) error
UpdateStack(ID StackID, stack *Stack) error
DeleteStack(ID StackID) error
}
// DockerHubService represents a service for managing the DockerHub object. // DockerHubService represents a service for managing the DockerHub object.
DockerHubService interface { DockerHubService interface {
DockerHub() (*DockerHub, error) DockerHub() (*DockerHub, error)
@ -325,10 +350,20 @@ type (
// FileService represents a service for managing files. // FileService represents a service for managing files.
FileService interface { FileService interface {
GetFileContent(filePath string) (string, error)
RemoveDirectory(directoryPath string) error
StoreTLSFile(folder string, fileType TLSFileType, r io.Reader) error StoreTLSFile(folder string, fileType TLSFileType, r io.Reader) error
GetPathForTLSFile(folder string, fileType TLSFileType) (string, error) GetPathForTLSFile(folder string, fileType TLSFileType) (string, error)
DeleteTLSFile(folder string, fileType TLSFileType) error DeleteTLSFile(folder string, fileType TLSFileType) error
DeleteTLSFiles(folder string) error DeleteTLSFiles(folder string) error
GetStackProjectPath(stackIdentifier string) string
StoreStackFileFromString(stackIdentifier string, stackFileContent string) (string, error)
StoreStackFileFromReader(stackIdentifier string, r io.Reader) (string, error)
}
// GitService represents a service for managing Git.
GitService interface {
CloneRepository(url, destination string) error
} }
// EndpointWatcher represents a service to synchronize the endpoints via an external source. // EndpointWatcher represents a service to synchronize the endpoints via an external source.
@ -341,13 +376,19 @@ type (
AuthenticateUser(username, password string, settings *LDAPSettings) error AuthenticateUser(username, password string, settings *LDAPSettings) error
TestConnectivity(settings *LDAPSettings) error TestConnectivity(settings *LDAPSettings) error
} }
// StackManager represents a service to manage stacks.
StackManager interface {
Deploy(stack *Stack, endpoint *Endpoint) error
Remove(stack *Stack, endpoint *Endpoint) error
}
) )
const ( const (
// APIVersion is the version number of the Portainer API. // APIVersion is the version number of the Portainer API.
APIVersion = "1.14.3" APIVersion = "1.15.0"
// DBVersion is the version number of the Portainer database. // DBVersion is the version number of the Portainer database.
DBVersion = 4 DBVersion = 6
// DefaultTemplatesURL represents the default URL for the templates definitions. // DefaultTemplatesURL represents the default URL for the templates definitions.
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
) )
@ -403,4 +444,6 @@ const (
NetworkResourceControl NetworkResourceControl
// SecretResourceControl represents a resource control associated to a Docker secret // SecretResourceControl represents a resource control associated to a Docker secret
SecretResourceControl SecretResourceControl
// StackResourceControl represents a resource control associated to a stack composed of Docker services
StackResourceControl
) )

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). **NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8).
version: "1.14.3" version: "1.15.0"
title: "Portainer API" title: "Portainer API"
contact: contact:
email: "info@portainer.io" email: "info@portainer.io"
@ -1869,7 +1869,7 @@ definitions:
description: "Is analytics enabled" description: "Is analytics enabled"
Version: Version:
type: "string" type: "string"
example: "1.14.3" example: "1.15.0"
description: "Portainer API version" description: "Portainer API version"
PublicSettingsInspectResponse: PublicSettingsInspectResponse:
type: "object" type: "object"
@ -1889,7 +1889,14 @@ definitions:
type: "integer" type: "integer"
example: 1 example: 1
description: "Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP." description: "Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP."
AllowBindMountsForRegularUsers:
type: "boolean"
example: false
description: "Whether non-administrator should be able to use bind mounts when creating containers"
AllowPrivilegedModeForRegularUsers:
type: "boolean"
example: true
description: "Whether non-administrator should be able to use privileged mode when creating containers"
TLSConfiguration: TLSConfiguration:
type: "object" type: "object"
properties: properties:
@ -1987,6 +1994,14 @@ definitions:
description: "Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP." description: "Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP."
LDAPSettings: LDAPSettings:
$ref: "#/definitions/LDAPSettings" $ref: "#/definitions/LDAPSettings"
AllowBindMountsForRegularUsers:
type: "boolean"
example: false
description: "Whether non-administrator should be able to use bind mounts when creating containers"
AllowPrivilegedModeForRegularUsers:
type: "boolean"
example: true
description: "Whether non-administrator should be able to use privileged mode when creating containers"
Settings_BlackListedLabels: Settings_BlackListedLabels:
properties: properties:
name: name:
@ -2394,6 +2409,14 @@ definitions:
description: "Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP." description: "Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP."
LDAPSettings: LDAPSettings:
$ref: "#/definitions/LDAPSettings" $ref: "#/definitions/LDAPSettings"
AllowBindMountsForRegularUsers:
type: "boolean"
example: true
description: "Whether non-administrator users should be able to use bind mounts when creating containers"
AllowPrivilegedModeForRegularUsers:
type: "boolean"
example: true
description: "Whether non-administrator users should be able to use privileged mode when creating containers"
UserCreateRequest: UserCreateRequest:
type: "object" type: "object"
required: required:
@ -2546,6 +2569,10 @@ definitions:
UserAdminInitRequest: UserAdminInitRequest:
type: "object" type: "object"
properties: properties:
Username:
type: "string"
example: "admin"
description: "Username for the admin user"
Password: Password:
type: "string" type: "string"
example: "admin-password" example: "admin-password"

68
app/__module.js Normal file
View File

@ -0,0 +1,68 @@
angular.module('portainer', [
'ui.bootstrap',
'ui.router',
'isteven-multi-select',
'ngCookies',
'ngSanitize',
'ngFileUpload',
'angularUtils.directives.dirPagination',
'LocalStorageModule',
'angular-jwt',
'angular-google-analytics',
'portainer.templates',
'portainer.filters',
'portainer.rest',
'portainer.helpers',
'portainer.services',
'auth',
'dashboard',
'container',
'containerConsole',
'containerLogs',
'containerStats',
'serviceLogs',
'containers',
'createContainer',
'createNetwork',
'createRegistry',
'createSecret',
'createService',
'createVolume',
'createStack',
'engine',
'endpoint',
'endpointAccess',
'endpoints',
'events',
'image',
'images',
'initAdmin',
'initEndpoint',
'main',
'network',
'networks',
'node',
'registries',
'registry',
'registryAccess',
'secrets',
'secret',
'service',
'services',
'settings',
'settingsAuthentication',
'sidebar',
'stack',
'stacks',
'swarm',
'swarmVisualizer',
'task',
'team',
'teams',
'templates',
'user',
'users',
'userSettings',
'volume',
'volumes',
'rzModule']);

View File

@ -1,781 +1,36 @@
angular.module('portainer.filters', []); angular.module('portainer')
angular.module('portainer.rest', ['ngResource']); .run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics) {
angular.module('portainer.services', []);
angular.module('portainer.helpers', []);
angular.module('portainer', [
'ui.bootstrap',
'ui.router',
'isteven-multi-select',
'ngCookies',
'ngSanitize',
'ngFileUpload',
'angularUtils.directives.dirPagination',
'LocalStorageModule',
'angular-jwt',
'angular-google-analytics',
'portainer.templates',
'portainer.filters',
'portainer.rest',
'portainer.helpers',
'portainer.services',
'auth',
'dashboard',
'container',
'containerConsole',
'containerLogs',
'containerStats',
'serviceLogs',
'containers',
'createContainer',
'createNetwork',
'createRegistry',
'createSecret',
'createService',
'createVolume',
'engine',
'endpoint',
'endpointAccess',
'endpoints',
'events',
'image',
'images',
'initAdmin',
'initEndpoint',
'main',
'network',
'networks',
'node',
'registries',
'registry',
'registryAccess',
'secrets',
'secret',
'service',
'services',
'settings',
'settingsAuthentication',
'sidebar',
'swarm',
'swarmVisualizer',
'task',
'team',
'teams',
'templates',
'user',
'users',
'userSettings',
'volume',
'volumes',
'rzModule'])
.config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', 'AnalyticsProvider', '$uibTooltipProvider', '$compileProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider, $uibTooltipProvider, $compileProvider) {
'use strict'; 'use strict';
var environment = '@@ENVIRONMENT';
if (environment === 'production') {
$compileProvider.debugInfoEnabled(false);
}
localStorageServiceProvider
.setPrefix('portainer');
jwtOptionsProvider.config({
tokenGetter: ['LocalStorage', function(LocalStorage) {
return LocalStorage.getJWT();
}],
unauthenticatedRedirector: ['$state', function($state) {
$state.go('auth', {error: 'Your session has expired'});
}]
});
$httpProvider.interceptors.push('jwtInterceptor');
AnalyticsProvider.setAccount('@@CONFIG_GA_ID');
AnalyticsProvider.startOffline(true);
$urlRouterProvider.otherwise('/auth');
toastr.options.timeOut = 3000;
$uibTooltipProvider.setTriggers({
'mouseenter': 'mouseleave',
'click': 'click',
'focus': 'blur',
'outsideClick': 'outsideClick'
});
$stateProvider
.state('root', {
abstract: true,
resolve: {
requiresLogin: ['StateManager', function (StateManager) {
var applicationState = StateManager.getState();
return applicationState.application.authentication;
}]
}
})
.state('auth', {
parent: 'root',
url: '/auth',
params: {
logout: false,
error: ''
},
views: {
'content@': {
templateUrl: 'app/components/auth/auth.html',
controller: 'AuthenticationController'
}
},
data: {
requiresLogin: false
}
})
.state('containers', {
parent: 'root',
url: '/containers/',
views: {
'content@': {
templateUrl: 'app/components/containers/containers.html',
controller: 'ContainersController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('container', {
url: '^/containers/:id',
views: {
'content@': {
templateUrl: 'app/components/container/container.html',
controller: 'ContainerController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('stats', {
url: '^/containers/:id/stats',
views: {
'content@': {
templateUrl: 'app/components/containerStats/containerStats.html',
controller: 'ContainerStatsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('containerlogs', {
url: '^/containers/:id/logs',
views: {
'content@': {
templateUrl: 'app/components/containerLogs/containerlogs.html',
controller: 'ContainerLogsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('servicelogs', {
url: '^/services/:id/logs',
views: {
'content@': {
templateUrl: 'app/components/serviceLogs/servicelogs.html',
controller: 'ServiceLogsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('console', {
url: '^/containers/:id/console',
views: {
'content@': {
templateUrl: 'app/components/containerConsole/containerConsole.html',
controller: 'ContainerConsoleController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('dashboard', {
parent: 'root',
url: '/dashboard',
views: {
'content@': {
templateUrl: 'app/components/dashboard/dashboard.html',
controller: 'DashboardController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('actions', {
abstract: true,
url: '/actions',
views: {
'content@': {
template: '<div ui-view="content@"></div>'
},
'sidebar@': {
template: '<div ui-view="sidebar@"></div>'
}
}
})
.state('actions.create', {
abstract: true,
url: '/create',
views: {
'content@': {
template: '<div ui-view="content@"></div>'
},
'sidebar@': {
template: '<div ui-view="sidebar@"></div>'
}
}
})
.state('actions.create.container', {
url: '/container/:from',
views: {
'content@': {
templateUrl: 'app/components/createContainer/createcontainer.html',
controller: 'CreateContainerController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('actions.create.network', {
url: '/network',
views: {
'content@': {
templateUrl: 'app/components/createNetwork/createnetwork.html',
controller: 'CreateNetworkController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('actions.create.registry', {
url: '/registry',
views: {
'content@': {
templateUrl: 'app/components/createRegistry/createregistry.html',
controller: 'CreateRegistryController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('actions.create.secret', {
url: '/secret',
views: {
'content@': {
templateUrl: 'app/components/createSecret/createsecret.html',
controller: 'CreateSecretController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('actions.create.service', {
url: '/service',
views: {
'content@': {
templateUrl: 'app/components/createService/createservice.html',
controller: 'CreateServiceController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('actions.create.volume', {
url: '/volume',
views: {
'content@': {
templateUrl: 'app/components/createVolume/createvolume.html',
controller: 'CreateVolumeController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('init', {
abstract: true,
url: '/init',
views: {
'content@': {
template: '<div ui-view="content@"></div>'
}
}
})
.state('init.endpoint', {
url: '/endpoint',
views: {
'content@': {
templateUrl: 'app/components/initEndpoint/initEndpoint.html',
controller: 'InitEndpointController'
}
}
})
.state('init.admin', {
url: '/admin',
views: {
'content@': {
templateUrl: 'app/components/initAdmin/initAdmin.html',
controller: 'InitAdminController'
}
}
})
.state('engine', {
url: '/engine/',
views: {
'content@': {
templateUrl: 'app/components/engine/engine.html',
controller: 'EngineController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('endpoints', {
url: '/endpoints/',
views: {
'content@': {
templateUrl: 'app/components/endpoints/endpoints.html',
controller: 'EndpointsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('endpoint', {
url: '^/endpoints/:id',
views: {
'content@': {
templateUrl: 'app/components/endpoint/endpoint.html',
controller: 'EndpointController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('endpoint.access', {
url: '^/endpoints/:id/access',
views: {
'content@': {
templateUrl: 'app/components/endpointAccess/endpointAccess.html',
controller: 'EndpointAccessController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('events', {
url: '/events/',
views: {
'content@': {
templateUrl: 'app/components/events/events.html',
controller: 'EventsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('images', {
url: '/images/',
views: {
'content@': {
templateUrl: 'app/components/images/images.html',
controller: 'ImagesController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('image', {
url: '^/images/:id/',
views: {
'content@': {
templateUrl: 'app/components/image/image.html',
controller: 'ImageController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('networks', {
url: '/networks/',
views: {
'content@': {
templateUrl: 'app/components/networks/networks.html',
controller: 'NetworksController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('network', {
url: '^/networks/:id/',
views: {
'content@': {
templateUrl: 'app/components/network/network.html',
controller: 'NetworkController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('node', {
url: '^/nodes/:id/',
views: {
'content@': {
templateUrl: 'app/components/node/node.html',
controller: 'NodeController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('registries', {
url: '/registries/',
views: {
'content@': {
templateUrl: 'app/components/registries/registries.html',
controller: 'RegistriesController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('registry', {
url: '^/registries/:id',
views: {
'content@': {
templateUrl: 'app/components/registry/registry.html',
controller: 'RegistryController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('registry.access', {
url: '^/registries/:id/access',
views: {
'content@': {
templateUrl: 'app/components/registryAccess/registryAccess.html',
controller: 'RegistryAccessController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('secrets', {
url: '^/secrets/',
views: {
'content@': {
templateUrl: 'app/components/secrets/secrets.html',
controller: 'SecretsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('secret', {
url: '^/secret/:id/',
views: {
'content@': {
templateUrl: 'app/components/secret/secret.html',
controller: 'SecretController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('services', {
url: '/services/',
views: {
'content@': {
templateUrl: 'app/components/services/services.html',
controller: 'ServicesController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('service', {
url: '^/service/:id/',
views: {
'content@': {
templateUrl: 'app/components/service/service.html',
controller: 'ServiceController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('settings', {
url: '/settings/',
views: {
'content@': {
templateUrl: 'app/components/settings/settings.html',
controller: 'SettingsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('settings_authentication', {
url: '^/settings/authentication',
views: {
'content@': {
templateUrl: 'app/components/settingsAuthentication/settingsAuthentication.html',
controller: 'SettingsAuthenticationController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('task', {
url: '^/task/:id',
views: {
'content@': {
templateUrl: 'app/components/task/task.html',
controller: 'TaskController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('templates', {
url: '/templates/',
params: {
key: 'containers',
hide_descriptions: false
},
views: {
'content@': {
templateUrl: 'app/components/templates/templates.html',
controller: 'TemplatesController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('templates_linuxserver', {
url: '^/templates/linuxserver.io',
params: {
key: 'linuxserver.io',
hide_descriptions: true
},
views: {
'content@': {
templateUrl: 'app/components/templates/templates.html',
controller: 'TemplatesController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('volumes', {
url: '/volumes/',
views: {
'content@': {
templateUrl: 'app/components/volumes/volumes.html',
controller: 'VolumesController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('volume', {
url: '^/volumes/:id',
views: {
'content@': {
templateUrl: 'app/components/volume/volume.html',
controller: 'VolumeController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('users', {
url: '/users/',
views: {
'content@': {
templateUrl: 'app/components/users/users.html',
controller: 'UsersController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('user', {
url: '^/users/:id',
views: {
'content@': {
templateUrl: 'app/components/user/user.html',
controller: 'UserController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('userSettings', {
url: '/userSettings/',
views: {
'content@': {
templateUrl: 'app/components/userSettings/userSettings.html',
controller: 'UserSettingsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('teams', {
url: '/teams/',
views: {
'content@': {
templateUrl: 'app/components/teams/teams.html',
controller: 'TeamsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('team', {
url: '^/teams/:id',
views: {
'content@': {
templateUrl: 'app/components/team/team.html',
controller: 'TeamController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('swarm', {
url: '/swarm',
views: {
'content@': {
templateUrl: 'app/components/swarm/swarm.html',
controller: 'SwarmController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('swarm.visualizer', {
url: '/visualizer',
views: {
'content@': {
templateUrl: 'app/components/swarmVisualizer/swarmVisualizer.html',
controller: 'SwarmVisualizerController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
;
}])
.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics) {
EndpointProvider.initialize(); EndpointProvider.initialize();
StateManager.initialize().then(function success(state) {
StateManager.initialize()
.then(function success(state) {
if (state.application.authentication) { if (state.application.authentication) {
initAuthentication(authManager, Authentication, $rootScope);
}
if (state.application.analytics) {
initAnalytics(Analytics, $rootScope);
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve application settings');
});
$rootScope.$state = $state;
}]);
function initAuthentication(authManager, Authentication, $rootScope) {
authManager.checkAuthOnRefresh(); authManager.checkAuthOnRefresh();
authManager.redirectWhenUnauthenticated(); authManager.redirectWhenUnauthenticated();
Authentication.init(); Authentication.init();
$rootScope.$on('tokenHasExpired', function($state) { $rootScope.$on('tokenHasExpired', function() {
$state.go('auth', {error: 'Your session has expired'}); $state.go('auth', {error: 'Your session has expired'});
}); });
} }
if (state.application.analytics) {
function initAnalytics(Analytics, $rootScope) {
Analytics.offline(false); Analytics.offline(false);
Analytics.registerScriptTags(); Analytics.registerScriptTags();
Analytics.registerTrackers(); Analytics.registerTrackers();
@ -783,26 +38,4 @@ angular.module('portainer', [
Analytics.trackPage(toState.url); Analytics.trackPage(toState.url);
Analytics.pageView(); Analytics.pageView();
}); });
} }
}, function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve application settings');
});
$rootScope.$state = $state;
}])
// This is your docker url that the api will use to make requests
// You need to set this to the api endpoint without the port i.e. http://192.168.1.9
// .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is required. If you have a port, prefix it with a ':' i.e. :4243
.constant('API_ENDPOINT_AUTH', 'api/auth')
.constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub')
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
.constant('API_ENDPOINT_STATUS', 'api/status')
.constant('API_ENDPOINT_USERS', 'api/users')
.constant('API_ENDPOINT_TEAMS', 'api/teams')
.constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships')
.constant('API_ENDPOINT_TEMPLATES', 'api/templates')
.constant('DEFAULT_TEMPLATES_URL', 'https://raw.githubusercontent.com/portainer/templates/master/templates.json')
.constant('PAGINATION_MAX_ITEMS', 10);

View File

@ -1,6 +1,6 @@
angular.module('auth', []) angular.module('auth', [])
.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Authentication', 'Users', 'UserService', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'SettingsService', .controller('AuthenticationController', ['$scope', '$state', '$transition$', '$window', '$timeout', '$sanitize', 'Authentication', 'Users', 'UserService', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'SettingsService',
function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Authentication, Users, UserService, EndpointService, StateManager, EndpointProvider, Notifications, SettingsService) { function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentication, Users, UserService, EndpointService, StateManager, EndpointProvider, Notifications, SettingsService) {
$scope.logo = StateManager.getState().application.logo; $scope.logo = StateManager.getState().application.logo;
@ -88,9 +88,9 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Authentica
}; };
function initView() { function initView() {
if ($stateParams.logout || $stateParams.error) { if ($transition$.params().logout || $transition$.params().error) {
Authentication.logout(); Authentication.logout();
$scope.state.AuthenticationError = $stateParams.error; $scope.state.AuthenticationError = $transition$.params().error;
return; return;
} }

View File

@ -1,6 +1,6 @@
angular.module('container', []) angular.module('container', [])
.controller('ContainerController', ['$q', '$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'Pagination', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService', .controller('ContainerController', ['$q', '$scope', '$state','$transition$', '$filter', 'Container', 'ContainerCommit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'Pagination', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService',
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, Pagination, ModalService, ResourceControlService, RegistryService, ImageService) { function ($q, $scope, $state, $transition$, $filter, Container, ContainerCommit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, Pagination, ModalService, ResourceControlService, RegistryService, ImageService) {
$scope.activityTime = 0; $scope.activityTime = 0;
$scope.portBindings = []; $scope.portBindings = [];
$scope.config = { $scope.config = {
@ -16,7 +16,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
var update = function () { var update = function () {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
Container.get({id: $stateParams.id}, function (d) { Container.get({id: $transition$.params().id}, function (d) {
var container = new ContainerDetailsViewModel(d); var container = new ContainerDetailsViewModel(d);
$scope.container = container; $scope.container = container;
$scope.container.edit = false; $scope.container.edit = false;
@ -52,7 +52,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
Container.start({id: $scope.container.Id}, {}, function (d) { Container.start({id: $scope.container.Id}, {}, function (d) {
update(); update();
Notifications.success('Container started', $stateParams.id); Notifications.success('Container started', $transition$.params().id);
}, function (e) { }, function (e) {
update(); update();
Notifications.error('Failure', e, 'Unable to start container'); Notifications.error('Failure', e, 'Unable to start container');
@ -61,9 +61,9 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
$scope.stop = function () { $scope.stop = function () {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
Container.stop({id: $stateParams.id}, function (d) { Container.stop({id: $transition$.params().id}, function (d) {
update(); update();
Notifications.success('Container stopped', $stateParams.id); Notifications.success('Container stopped', $transition$.params().id);
}, function (e) { }, function (e) {
update(); update();
Notifications.error('Failure', e, 'Unable to stop container'); Notifications.error('Failure', e, 'Unable to stop container');
@ -72,9 +72,9 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
$scope.kill = function () { $scope.kill = function () {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
Container.kill({id: $stateParams.id}, function (d) { Container.kill({id: $transition$.params().id}, function (d) {
update(); update();
Notifications.success('Container killed', $stateParams.id); Notifications.success('Container killed', $transition$.params().id);
}, function (e) { }, function (e) {
update(); update();
Notifications.error('Failure', e, 'Unable to kill container'); Notifications.error('Failure', e, 'Unable to kill container');
@ -86,10 +86,10 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
var image = $scope.config.Image; var image = $scope.config.Image;
var registry = $scope.config.Registry; var registry = $scope.config.Registry;
var imageConfig = ImageHelper.createImageConfigForCommit(image, registry.URL); var imageConfig = ImageHelper.createImageConfigForCommit(image, registry.URL);
ContainerCommit.commit({id: $stateParams.id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) { ContainerCommit.commit({id: $transition$.params().id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) {
$('#createImageSpinner').hide(); $('#createImageSpinner').hide();
update(); update();
Notifications.success('Container commited', $stateParams.id); Notifications.success('Container commited', $transition$.params().id);
}, function (e) { }, function (e) {
$('#createImageSpinner').hide(); $('#createImageSpinner').hide();
update(); update();
@ -99,9 +99,9 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
$scope.pause = function () { $scope.pause = function () {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
Container.pause({id: $stateParams.id}, function (d) { Container.pause({id: $transition$.params().id}, function (d) {
update(); update();
Notifications.success('Container paused', $stateParams.id); Notifications.success('Container paused', $transition$.params().id);
}, function (e) { }, function (e) {
update(); update();
Notifications.error('Failure', e, 'Unable to pause container'); Notifications.error('Failure', e, 'Unable to pause container');
@ -110,9 +110,9 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
$scope.unpause = function () { $scope.unpause = function () {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
Container.unpause({id: $stateParams.id}, function (d) { Container.unpause({id: $transition$.params().id}, function (d) {
update(); update();
Notifications.success('Container unpaused', $stateParams.id); Notifications.success('Container unpaused', $transition$.params().id);
}, function (e) { }, function (e) {
update(); update();
Notifications.error('Failure', e, 'Unable to unpause container'); Notifications.error('Failure', e, 'Unable to unpause container');
@ -154,9 +154,9 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
$scope.restart = function () { $scope.restart = function () {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
Container.restart({id: $stateParams.id}, function (d) { Container.restart({id: $transition$.params().id}, function (d) {
update(); update();
Notifications.success('Container restarted', $stateParams.id); Notifications.success('Container restarted', $transition$.params().id);
}, function (e) { }, function (e) {
update(); update();
Notifications.error('Failure', e, 'Unable to restart container'); Notifications.error('Failure', e, 'Unable to restart container');
@ -165,7 +165,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
$scope.renameContainer = function () { $scope.renameContainer = function () {
var container = $scope.container; var container = $scope.container;
Container.rename({id: $stateParams.id, 'name': container.newContainerName}, function (d) { Container.rename({id: $transition$.params().id, 'name': container.newContainerName}, function (d) {
if (d.message) { if (d.message) {
container.newContainerName = container.Name; container.newContainerName = container.Name;
Notifications.error('Unable to rename container', {}, d.message); Notifications.error('Unable to rename container', {}, d.message);
@ -181,14 +181,14 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
$scope.containerLeaveNetwork = function containerLeaveNetwork(container, networkId) { $scope.containerLeaveNetwork = function containerLeaveNetwork(container, networkId) {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
Network.disconnect({id: networkId}, { Container: $stateParams.id, Force: false }, function (d) { Network.disconnect({id: networkId}, { Container: $transition$.params().id, Force: false }, function (d) {
if (container.message) { if (container.message) {
$('#loadingViewSpinner').hide(); $('#loadingViewSpinner').hide();
Notifications.error('Error', d, 'Unable to disconnect container from network'); Notifications.error('Error', d, 'Unable to disconnect container from network');
} else { } else {
$('#loadingViewSpinner').hide(); $('#loadingViewSpinner').hide();
Notifications.success('Container left network', $stateParams.id); Notifications.success('Container left network', $transition$.params().id);
$state.go('container', {id: $stateParams.id}, {reload: true}); $state.go('container', {id: $transition$.params().id}, {reload: true});
} }
}, function (e) { }, function (e) {
$('#loadingViewSpinner').hide(); $('#loadingViewSpinner').hide();
@ -199,7 +199,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
$scope.duplicate = function() { $scope.duplicate = function() {
ModalService.confirmExperimentalFeature(function (experimental) { ModalService.confirmExperimentalFeature(function (experimental) {
if(!experimental) { return; } if(!experimental) { return; }
$state.go('actions.create.container', {from: $stateParams.id}, {reload: true}); $state.go('actions.create.container', {from: $transition$.params().id}, {reload: true});
}); });
}; };
@ -280,14 +280,14 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
$scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) { $scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) {
$('#joinNetworkSpinner').show(); $('#joinNetworkSpinner').show();
Network.connect({id: networkId}, { Container: $stateParams.id }, function (d) { Network.connect({id: networkId}, { Container: $transition$.params().id }, function (d) {
if (container.message) { if (container.message) {
$('#joinNetworkSpinner').hide(); $('#joinNetworkSpinner').hide();
Notifications.error('Error', d, 'Unable to connect container to network'); Notifications.error('Error', d, 'Unable to connect container to network');
} else { } else {
$('#joinNetworkSpinner').hide(); $('#joinNetworkSpinner').hide();
Notifications.success('Container joined network', $stateParams.id); Notifications.success('Container joined network', $transition$.params().id);
$state.go('container', {id: $stateParams.id}, {reload: true}); $state.go('container', {id: $transition$.params().id}, {reload: true});
} }
}, function (e) { }, function (e) {
$('#joinNetworkSpinner').hide(); $('#joinNetworkSpinner').hide();

View File

@ -1,6 +1,6 @@
angular.module('containerConsole', []) angular.module('containerConsole', [])
.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Container', 'Image', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ContainerService', 'ExecService', .controller('ContainerConsoleController', ['$scope', '$transition$', 'Container', 'Image', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ContainerService', 'ExecService',
function ($scope, $stateParams, Container, Image, EndpointProvider, Notifications, ContainerHelper, ContainerService, ExecService) { function ($scope, $transition$, Container, Image, EndpointProvider, Notifications, ContainerHelper, ContainerService, ExecService) {
$scope.state = {}; $scope.state = {};
$scope.state.loaded = false; $scope.state.loaded = false;
$scope.state.connected = false; $scope.state.connected = false;
@ -15,7 +15,7 @@ function ($scope, $stateParams, Container, Image, EndpointProvider, Notification
} }
}); });
Container.get({id: $stateParams.id}, function(d) { Container.get({id: $transition$.params().id}, function(d) {
$scope.container = d; $scope.container = d;
if (d.message) { if (d.message) {
Notifications.error('Error', d, 'Unable to retrieve container details'); Notifications.error('Error', d, 'Unable to retrieve container details');
@ -43,7 +43,7 @@ function ($scope, $stateParams, Container, Image, EndpointProvider, Notification
var command = $scope.formValues.isCustomCommand ? var command = $scope.formValues.isCustomCommand ?
$scope.formValues.customCommand : $scope.formValues.command; $scope.formValues.customCommand : $scope.formValues.command;
var execConfig = { var execConfig = {
id: $stateParams.id, id: $transition$.params().id,
AttachStdin: true, AttachStdin: true,
AttachStdout: true, AttachStdout: true,
AttachStderr: true, AttachStderr: true,

View File

@ -1,6 +1,6 @@
angular.module('containerLogs', []) angular.module('containerLogs', [])
.controller('ContainerLogsController', ['$scope', '$stateParams', '$anchorScroll', 'ContainerLogs', 'Container', .controller('ContainerLogsController', ['$scope', '$transition$', '$anchorScroll', 'ContainerLogs', 'Container',
function ($scope, $stateParams, $anchorScroll, ContainerLogs, Container) { function ($scope, $transition$, $anchorScroll, ContainerLogs, Container) {
$scope.state = {}; $scope.state = {};
$scope.state.displayTimestampsOut = false; $scope.state.displayTimestampsOut = false;
$scope.state.displayTimestampsErr = false; $scope.state.displayTimestampsErr = false;
@ -9,7 +9,7 @@ function ($scope, $stateParams, $anchorScroll, ContainerLogs, Container) {
$scope.tailLines = 2000; $scope.tailLines = 2000;
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
Container.get({id: $stateParams.id}, function (d) { Container.get({id: $transition$.params().id}, function (d) {
$scope.container = d; $scope.container = d;
$('#loadingViewSpinner').hide(); $('#loadingViewSpinner').hide();
}, function (e) { }, function (e) {
@ -25,7 +25,7 @@ function ($scope, $stateParams, $anchorScroll, ContainerLogs, Container) {
} }
function getLogsStderr() { function getLogsStderr() {
ContainerLogs.get($stateParams.id, { ContainerLogs.get($transition$.params().id, {
stdout: 0, stdout: 0,
stderr: 1, stderr: 1,
timestamps: $scope.state.displayTimestampsErr, timestamps: $scope.state.displayTimestampsErr,
@ -41,7 +41,7 @@ function ($scope, $stateParams, $anchorScroll, ContainerLogs, Container) {
} }
function getLogsStdout() { function getLogsStdout() {
ContainerLogs.get($stateParams.id, { ContainerLogs.get($transition$.params().id, {
stdout: 1, stdout: 1,
stderr: 0, stderr: 0,
timestamps: $scope.state.displayTimestampsOut, timestamps: $scope.state.displayTimestampsOut,

View File

@ -38,6 +38,13 @@
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i> <i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
</span> </span>
</div> </div>
<div class="form-group" ng-if="state.networkStatsUnavailable">
<div class="col-sm-12">
<span class="small text-muted">
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true"></i> Network stats are unavailable for this container.
</span>
</div>
</div>
</form> </form>
</rd-widget-body> </rd-widget-body>
</rd-widget> </rd-widget>
@ -45,7 +52,8 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-lg-4 col-md-6 col-sm-12"> <div ng-class="{true: 'col-md-6 col-sm-12', false: 'col-lg-4 col-md-6 col-sm-12'}[state.networkStatsUnavailable]">
<!-- <div class="col-lg-4 col-md-6 col-sm-12"> -->
<rd-widget> <rd-widget>
<rd-widget-header icon="fa-area-chart" title="Memory usage"></rd-widget-header> <rd-widget-header icon="fa-area-chart" title="Memory usage"></rd-widget-header>
<rd-widget-body> <rd-widget-body>
@ -55,7 +63,7 @@
</rd-widget-body> </rd-widget-body>
</rd-widget> </rd-widget>
</div> </div>
<div class="col-lg-4 col-md-6 col-sm-12"> <div ng-class="{true: 'col-md-6 col-sm-12', false: 'col-lg-4 col-md-6 col-sm-12'}[state.networkStatsUnavailable]">
<rd-widget> <rd-widget>
<rd-widget-header icon="fa-area-chart" title="CPU usage"></rd-widget-header> <rd-widget-header icon="fa-area-chart" title="CPU usage"></rd-widget-header>
<rd-widget-body> <rd-widget-body>
@ -65,7 +73,7 @@
</rd-widget-body> </rd-widget-body>
</rd-widget> </rd-widget>
</div> </div>
<div class="col-lg-4 col-md-12 col-sm-12"> <div class="col-lg-4 col-md-12 col-sm-12" ng-if="!state.networkStatsUnavailable">
<rd-widget> <rd-widget>
<rd-widget-header icon="fa-area-chart" title="Network usage"></rd-widget-header> <rd-widget-header icon="fa-area-chart" title="Network usage"></rd-widget-header>
<rd-widget-body> <rd-widget-body>

View File

@ -1,9 +1,10 @@
angular.module('containerStats', []) angular.module('containerStats', [])
.controller('ContainerStatsController', ['$q', '$scope', '$stateParams', '$document', '$interval', 'ContainerService', 'ChartService', 'Notifications', 'Pagination', .controller('ContainerStatsController', ['$q', '$scope', '$transition$', '$document', '$interval', 'ContainerService', 'ChartService', 'Notifications', 'Pagination',
function ($q, $scope, $stateParams, $document, $interval, ContainerService, ChartService, Notifications, Pagination) { function ($q, $scope, $transition$, $document, $interval, ContainerService, ChartService, Notifications, Pagination) {
$scope.state = { $scope.state = {
refreshRate: '5' refreshRate: '5',
networkStatsUnavailable: false
}; };
$scope.state.pagination_count = Pagination.getPaginationCount('stats_processes'); $scope.state.pagination_count = Pagination.getPaginationCount('stats_processes');
@ -32,12 +33,14 @@ function ($q, $scope, $stateParams, $document, $interval, ContainerService, Char
} }
function updateNetworkChart(stats, chart) { function updateNetworkChart(stats, chart) {
if (stats.Networks.length > 0) {
var rx = stats.Networks[0].rx_bytes; var rx = stats.Networks[0].rx_bytes;
var tx = stats.Networks[0].tx_bytes; var tx = stats.Networks[0].tx_bytes;
var label = moment(stats.Date).format('HH:mm:ss'); var label = moment(stats.Date).format('HH:mm:ss');
ChartService.UpdateNetworkChart(label, rx, tx, chart); ChartService.UpdateNetworkChart(label, rx, tx, chart);
} }
}
function updateMemoryChart(stats, chart) { function updateMemoryChart(stats, chart) {
var label = moment(stats.Date).format('HH:mm:ss'); var label = moment(stats.Date).format('HH:mm:ss');
@ -79,12 +82,15 @@ function ($q, $scope, $stateParams, $document, $interval, ContainerService, Char
function startChartUpdate(networkChart, cpuChart, memoryChart) { function startChartUpdate(networkChart, cpuChart, memoryChart) {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
$q.all({ $q.all({
stats: ContainerService.containerStats($stateParams.id), stats: ContainerService.containerStats($transition$.params().id),
top: ContainerService.containerTop($stateParams.id) top: ContainerService.containerTop($transition$.params().id)
}) })
.then(function success(data) { .then(function success(data) {
var stats = data.stats; var stats = data.stats;
$scope.processInfo = data.top; $scope.processInfo = data.top;
if (stats.Networks.length === 0) {
$scope.state.networkStatsUnavailable = true;
}
updateNetworkChart(stats, networkChart); updateNetworkChart(stats, networkChart);
updateMemoryChart(stats, memoryChart); updateMemoryChart(stats, memoryChart);
updateCPUChart(stats, cpuChart); updateCPUChart(stats, cpuChart);
@ -103,8 +109,8 @@ function ($q, $scope, $stateParams, $document, $interval, ContainerService, Char
var refreshRate = $scope.state.refreshRate; var refreshRate = $scope.state.refreshRate;
$scope.repeater = $interval(function() { $scope.repeater = $interval(function() {
$q.all({ $q.all({
stats: ContainerService.containerStats($stateParams.id), stats: ContainerService.containerStats($transition$.params().id),
top: ContainerService.containerTop($stateParams.id) top: ContainerService.containerTop($transition$.params().id)
}) })
.then(function success(data) { .then(function success(data) {
var stats = data.stats; var stats = data.stats;
@ -139,7 +145,7 @@ function ($q, $scope, $stateParams, $document, $interval, ContainerService, Char
function initView() { function initView() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
ContainerService.container($stateParams.id) ContainerService.container($transition$.params().id)
.then(function success(data) { .then(function success(data) {
$scope.container = data; $scope.container = data;
}) })

View File

@ -49,14 +49,14 @@
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" /> <input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th> </th>
<th> <th>
<a ui-sref="containers" ng-click="order('Status')"> <a ng-click="order('Status')">
State State
<span ng-show="sortType == 'Status' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Status' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Status' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Status' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th> <th>
<a ui-sref="containers" ng-click="order('Names')"> <a ng-click="order('Names')">
Name Name
<span ng-show="sortType == 'Names' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Names' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Names' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Names' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
@ -66,35 +66,42 @@
</a> </a>
</th> </th>
<th> <th>
<a ui-sref="containers" ng-click="order('Image')"> <a ng-click="order('StackName')">
Stack
<span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('Image')">
Image Image
<span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <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 == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th ng-if="state.displayIP"> <th ng-if="state.displayIP">
<a ui-sref="containers" ng-click="order('IP')"> <a ng-click="order('IP')">
IP Address IP Address
<span ng-show="sortType == 'IP' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'IP' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'IP' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'IP' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'"> <th ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">
<a ui-sref="containers" ng-click="order('Host')"> <a ng-click="order('Host')">
Host IP Host IP
<span ng-show="sortType == 'Host' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Host' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Host' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Host' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th> <th>
<a ui-sref="containers" ng-click="order('Ports')"> <a ng-click="order('Ports')">
Published Ports Published Ports
<span ng-show="sortType == 'Ports' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Ports' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Ports' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Ports' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th ng-if="applicationState.application.authentication"> <th ng-if="applicationState.application.authentication">
<a ui-sref="containers" ng-click="order('ResourceControl.Ownership')"> <a ng-click="order('ResourceControl.Ownership')">
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-down"></span>
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
@ -111,6 +118,7 @@
</td> </td>
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|swarmcontainername|truncate: truncate_size}}</a></td> <td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|swarmcontainername|truncate: truncate_size}}</a></td>
<td ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|containername|truncate: truncate_size}}</a></td> <td ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|containername|truncate: truncate_size}}</a></td>
<td>{{ container.StackName ? container.StackName : '-' }}</td>
<td><a ui-sref="image({id: container.Image})">{{ container.Image | hideshasum }}</a></td> <td><a ui-sref="image({id: container.Image})">{{ container.Image | hideshasum }}</a></td>
<td ng-if="state.displayIP">{{ container.IP ? container.IP : '-' }}</td> <td ng-if="state.displayIP">{{ container.IP ? container.IP : '-' }}</td>
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">{{ container.hostIP }}</td> <td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">{{ container.hostIP }}</td>

View File

@ -1,8 +1,8 @@
// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. // @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services.
// See app/components/templates/templatesController.js as a reference. // See app/components/templates/templatesController.js as a reference.
angular.module('createContainer', []) angular.module('createContainer', [])
.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', .controller('CreateContainerController', ['$q', '$scope', '$state', '$timeout', '$transition$', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', 'SystemService', 'SettingsService',
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService) { function ($q, $scope, $state, $timeout, $transition$, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService, SystemService, SettingsService) {
$scope.formValues = { $scope.formValues = {
alwaysPull: true, alwaysPull: true,
@ -13,13 +13,22 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
ExtraHosts: [], ExtraHosts: [],
IPv4: '', IPv4: '',
IPv6: '', IPv6: '',
AccessControlData: new AccessControlFormData() AccessControlData: new AccessControlFormData(),
CpuLimit: 0,
MemoryLimit: 0,
MemoryReservation: 0
}; };
$scope.state = { $scope.state = {
formValidationError: '' formValidationError: ''
}; };
$scope.refreshSlider = function () {
$timeout(function () {
$scope.$broadcast('rzSliderForceRender');
});
};
$scope.config = { $scope.config = {
Image: '', Image: '',
Env: [], Env: [],
@ -221,6 +230,25 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
config.HostConfig.Devices = path; config.HostConfig.Devices = path;
} }
function prepareResources(config) {
// Memory Limit - Round to 0.125
var memoryLimit = (Math.round($scope.formValues.MemoryLimit * 8) / 8).toFixed(3);
memoryLimit *= 1024 * 1024;
if (memoryLimit > 0) {
config.HostConfig.Memory = memoryLimit;
}
// Memory Resevation - Round to 0.125
var memoryReservation = (Math.round($scope.formValues.MemoryReservation * 8) / 8).toFixed(3);
memoryReservation *= 1024 * 1024;
if (memoryReservation > 0) {
config.HostConfig.MemoryReservation = memoryReservation;
}
// CPU Limit
if ($scope.formValues.CpuLimit > 0) {
config.HostConfig.NanoCpus = $scope.formValues.CpuLimit * 1000000000;
}
}
function prepareConfiguration() { function prepareConfiguration() {
var config = angular.copy($scope.config); var config = angular.copy($scope.config);
config.Cmd = ContainerHelper.commandStringToArray(config.Cmd); config.Cmd = ContainerHelper.commandStringToArray(config.Cmd);
@ -232,6 +260,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
prepareVolumes(config); prepareVolumes(config);
prepareLabels(config); prepareLabels(config);
prepareDevices(config); prepareDevices(config);
prepareResources(config);
return config; return config;
} }
@ -416,9 +445,21 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
}); });
} }
function loadFromContainerResources(d) {
if (d.HostConfig.NanoCpus) {
$scope.formValues.CpuLimit = d.HostConfig.NanoCpus / 1000000000;
}
if (d.HostConfig.Memory) {
$scope.formValues.MemoryLimit = d.HostConfig.Memory / 1024 / 1024;
}
if (d.HostConfig.MemoryReservation) {
$scope.formValues.MemoryReservation = d.HostConfig.MemoryReservation / 1024 / 1024;
}
}
function loadFromContainerSpec() { function loadFromContainerSpec() {
// Get container // Get container
Container.get({ id: $stateParams.from }).$promise Container.get({ id: $transition$.params().from }).$promise
.then(function success(d) { .then(function success(d) {
var fromContainer = new ContainerDetailsViewModel(d); var fromContainer = new ContainerDetailsViewModel(d);
if (!fromContainer.ResourceControl) { if (!fromContainer.ResourceControl) {
@ -435,6 +476,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
loadFromContainerConsole(d); loadFromContainerConsole(d);
loadFromContainerDevices(d); loadFromContainerDevices(d);
loadFromContainerImageConfig(d); loadFromContainerImageConfig(d);
loadFromContainerResources(d);
}) })
.catch(function error(err) { .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve container'); Notifications.error('Failure', err, 'Unable to retrieve container');
@ -472,7 +514,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
Container.query({}, function (d) { Container.query({}, function (d) {
var containers = d; var containers = d;
$scope.runningContainers = containers; $scope.runningContainers = containers;
if ($stateParams.from !== '') { if ($transition$.params().from !== '') {
loadFromContainerSpec(); loadFromContainerSpec();
} else { } else {
$scope.fromContainer = {}; $scope.fromContainer = {};
@ -482,6 +524,32 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
Notifications.error('Failure', e, 'Unable to retrieve running containers'); Notifications.error('Failure', e, 'Unable to retrieve running containers');
}); });
SystemService.info()
.then(function success(data) {
$scope.state.sliderMaxCpu = 32;
if (data.NCPU) {
$scope.state.sliderMaxCpu = data.NCPU;
}
$scope.state.sliderMaxMemory = 32768;
if (data.MemTotal) {
$scope.state.sliderMaxMemory = Math.floor(data.MemTotal / 1000 / 1000);
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve engine details');
});
SettingsService.publicSettings()
.then(function success(data) {
$scope.allowBindMounts = data.AllowBindMountsForRegularUsers;
$scope.allowPrivilegedMode = data.AllowPrivilegedModeForRegularUsers;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve application settings');
});
var userDetails = Authentication.getUserDetails();
$scope.isAdmin = userDetails.role === 1 ? true : false;
} }
function validateForm(accessControlData, isAdmin) { function validateForm(accessControlData, isAdmin) {

View File

@ -141,7 +141,7 @@
<li class="interactive"><a data-target="#env" data-toggle="tab">Env</a></li> <li class="interactive"><a data-target="#env" data-toggle="tab">Env</a></li>
<li class="interactive"><a data-target="#labels" data-toggle="tab">Labels</a></li> <li class="interactive"><a data-target="#labels" data-toggle="tab">Labels</a></li>
<li class="interactive"><a data-target="#restart-policy" data-toggle="tab">Restart policy</a></li> <li class="interactive"><a data-target="#restart-policy" data-toggle="tab">Restart policy</a></li>
<li class="interactive"><a data-target="#runtime" data-toggle="tab">Runtime</a></li> <li class="interactive"><a data-target="#runtime-resources" ng-click="refreshSlider()" data-toggle="tab">Runtime & Resources</a></li>
</ul> </ul>
<!-- tab-content --> <!-- tab-content -->
<div class="tab-content"> <div class="tab-content">
@ -235,7 +235,7 @@
</div> </div>
<!-- !container-path --> <!-- !container-path -->
<!-- volume-type --> <!-- volume-type -->
<div class="input-group col-sm-5" style="margin-left: 5px;"> <div class="input-group col-sm-5" style="margin-left: 5px;" ng-if="isAdmin || allowBindMounts">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label> <label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.name = ''">Bind</label> <label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.name = ''">Bind</label>
@ -466,17 +466,20 @@
</form> </form>
</div> </div>
<!-- !tab-restart-policy --> <!-- !tab-restart-policy -->
<!-- tab-runtime --> <!-- tab-runtime-resources -->
<div class="tab-pane" id="runtime"> <div class="tab-pane" id="runtime-resources">
<form class="form-horizontal" style="margin-top: 15px;"> <form class="form-horizontal" style="margin-top: 15px;">
<div class="col-sm-12 form-section-title">
Runtime
</div>
<!-- privileged-mode --> <!-- privileged-mode -->
<div class="form-group"> <div class="form-group" ng-if="isAdmin || allowPrivilegedMode">
<div class="col-sm-12"> <div class="col-sm-12">
<label for="ownership" class="control-label text-left"> <label for="privileged_mode" class="control-label text-left">
Privileged mode Privileged mode
</label> </label>
<label class="switch" style="margin-left: 20px;"> <label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="config.HostConfig.Privileged"><i></i> <input type="checkbox" name="privileged_mode" ng-model="config.HostConfig.Privileged"><i></i>
</label> </label>
</div> </div>
</div> </div>
@ -510,10 +513,63 @@
<!-- !devices-input-list --> <!-- !devices-input-list -->
</div> </div>
<!-- !devices--> <!-- !devices-->
</form> <div class="col-sm-12 form-section-title">
Resources
</div> </div>
<!-- !tab-runtime --> <!-- memory-reservation-input -->
<div class="form-group">
<label for="memory-reservation" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px;">
Memory reservation
</label>
<div class="col-sm-3">
<por-slider model="formValues.MemoryReservation" floor="0" ceil="state.sliderMaxMemory" step="256" ng-if="state.sliderMaxMemory"></por-slider>
</div>
<div class="col-sm-2">
<input type="number" min="0" class="form-control" ng-model="formValues.MemoryReservation" id="memory-reservation">
</div>
<div class="col-sm-4">
<p class="small text-muted" style="margin-top: 7px;">
Memory soft limit (<b>MB</b>)
</p>
</div>
</div>
<!-- !memory-reservation-input -->
<!-- memory-limit-input -->
<div class="form-group">
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px;">
Memory limit
</label>
<div class="col-sm-3">
<por-slider model="formValues.MemoryLimit" floor="0" ceil="state.sliderMaxMemory" step="256" ng-if="state.sliderMaxMemory"></por-slider>
</div>
<div class="col-sm-2">
<input type="number" min="0" class="form-control" ng-model="formValues.MemoryLimit" id="memory-limit">
</div>
<div class="col-sm-4">
<p class="small text-muted" style="margin-top: 7px;">
Memory limit (<b>MB</b>)
</p>
</div>
</div>
<!-- !memory-limit-input -->
<!-- cpu-limit-input -->
<div class="form-group">
<label for="cpu-limit" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px;">
CPU limit
</label>
<div class="col-sm-5">
<por-slider model="formValues.CpuLimit" floor="0" ceil="state.sliderMaxCpu" step="0.25" precision="2" ng-if="state.sliderMaxCpu"></por-slider>
</div>
<div class="col-sm-4" style="margin-top: 20px;">
<p class="small text-muted">
Maximum CPU usage
</p>
</div>
</div>
<!-- !cpu-limit-input -->
</form>
</div>
<!-- !tab-runtime-resources -->
</div> </div>
</rd-widget-body> </rd-widget-body>
</rd-widget> </rd-widget>

View File

@ -1,8 +1,8 @@
// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. // @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services.
// See app/components/templates/templatesController.js as a reference. // See app/components/templates/templatesController.js as a reference.
angular.module('createService', []) angular.module('createService', [])
.controller('CreateServiceController', ['$q', '$scope', '$state', '$timeout', 'Service', 'ServiceHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'LabelHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'FormValidator', 'RegistryService', 'HttpRequestHelper', 'NodeService', .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) { function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, LabelHelper, Authentication, ResourceControlService, Notifications, FormValidator, RegistryService, HttpRequestHelper, NodeService, SettingsService) {
$scope.formValues = { $scope.formValues = {
Name: '', Name: '',
@ -352,6 +352,29 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se
createNewService(config, accessControlData); createNewService(config, accessControlData);
}; };
function initSlidersMaxValuesBasedOnNodeData(nodes) {
var maxCpus = 0;
var maxMemory = 0;
for (var n in nodes) {
if (nodes[n].CPUs && nodes[n].CPUs > maxCpus) {
maxCpus = nodes[n].CPUs;
}
if (nodes[n].Memory && nodes[n].Memory > maxMemory) {
maxMemory = nodes[n].Memory;
}
}
if (maxCpus > 0) {
$scope.state.sliderMaxCpu = maxCpus / 1000000000;
} else {
$scope.state.sliderMaxCpu = 32;
}
if (maxMemory > 0) {
$scope.state.sliderMaxMemory = Math.floor(maxMemory / 1000 / 1000);
} else {
$scope.state.sliderMaxMemory = 32768;
}
}
function initView() { function initView() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
var apiVersion = $scope.applicationState.endpoint.apiVersion; var apiVersion = $scope.applicationState.endpoint.apiVersion;
@ -361,24 +384,19 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se
volumes: VolumeService.volumes(), volumes: VolumeService.volumes(),
secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], secrets: apiVersion >= 1.25 ? SecretService.secrets() : [],
networks: NetworkService.networks(true, true, false, false), networks: NetworkService.networks(true, true, false, false),
nodes: NodeService.nodes() nodes: NodeService.nodes(),
settings: SettingsService.publicSettings()
}) })
.then(function success(data) { .then(function success(data) {
$scope.availableVolumes = data.volumes; $scope.availableVolumes = data.volumes;
$scope.availableNetworks = data.networks; $scope.availableNetworks = data.networks;
$scope.availableSecrets = data.secrets; $scope.availableSecrets = data.secrets;
// Set max cpu value var nodes = data.nodes;
var maxCpus = 0; initSlidersMaxValuesBasedOnNodeData(nodes);
for (var n in data.nodes) { var settings = data.settings;
if (data.nodes[n].CPUs && data.nodes[n].CPUs > maxCpus) { $scope.allowBindMounts = settings.AllowBindMountsForRegularUsers;
maxCpus = data.nodes[n].CPUs; var userDetails = Authentication.getUserDetails();
} $scope.isAdmin = userDetails.role === 1 ? true : false;
}
if (maxCpus > 0) {
$scope.state.sliderMaxCpu = maxCpus / 1000000000;
} else {
$scope.state.sliderMaxCpu = 32;
}
}) })
.catch(function error(err) { .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to initialize view'); Notifications.error('Failure', err, 'Unable to initialize view');

View File

@ -223,7 +223,7 @@
<!-- !container-path --> <!-- !container-path -->
<!-- volume-type --> <!-- volume-type -->
<div class="input-group col-sm-5" style="margin-left: 5px;"> <div class="input-group col-sm-5" style="margin-left: 5px;">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm" ng-if="isAdmin || allowBindMounts">
<label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label> <label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
<label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'bind'" ng-click="volume.Id = ''">Bind</label> <label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'bind'" ng-click="volume.Id = ''">Bind</label>
</div> </div>

View File

@ -4,42 +4,36 @@
</div> </div>
<!-- memory-reservation-input --> <!-- memory-reservation-input -->
<div class="form-group"> <div class="form-group">
<label for="memory-reservation" class="col-sm-3 col-lg-2 control-label text-left"> <label for="memory-reservation" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px;">
Memory reservation Memory reservation
</label> </label>
<div class="col-sm-3"> <div class="col-sm-3">
<input type="number" step="0.125" min="0" class="form-control" ng-model="formValues.MemoryReservation" id="memory-reservation" placeholder="e.g. 64"> <por-slider model="formValues.MemoryReservation" floor="0" ceil="state.sliderMaxMemory" step="256" ng-if="state.sliderMaxMemory"></por-slider>
</div> </div>
<div class="col-sm-2"> <div class="col-sm-2">
<select class="form-control" ng-model="formValues.MemoryReservationUnit"> <input type="number" min="0" class="form-control" ng-model="formValues.MemoryReservation">
<option value="MB">MB</option>
<option value="GB">GB</option>
</select>
</div> </div>
<div class="col-sm-4"> <div class="col-sm-4">
<p class="small text-muted"> <p class="small text-muted" style="margin-top: 7px;">
Minimum memory available on a node to run a task Minimum memory available on a node to run a task (<b>MB</b>)
</p> </p>
</div> </div>
</div> </div>
<!-- !memory-reservation-input --> <!-- !memory-reservation-input -->
<!-- memory-limit-input --> <!-- memory-limit-input -->
<div class="form-group"> <div class="form-group">
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label text-left"> <label for="memory-limit" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px;">
Memory limit Memory limit
</label> </label>
<div class="col-sm-3"> <div class="col-sm-3">
<input type="number" step="0.125" min="0" class="form-control" ng-model="formValues.MemoryLimit" id="memory-limit" placeholder="e.g. 128"> <por-slider model="formValues.MemoryLimit" floor="0" ceil="state.sliderMaxMemory" step="256" ng-if="state.sliderMaxMemory"></por-slider>
</div> </div>
<div class="col-sm-2"> <div class="col-sm-2">
<select class="form-control" ng-model="formValues.MemoryLimitUnit"> <input type="number" min="0" class="form-control" ng-model="formValues.MemoryLimit">
<option value="MB">MB</option>
<option value="GB">GB</option>
</select>
</div> </div>
<div class="col-sm-4"> <div class="col-sm-4" style="margin-top: 7px;">
<p class="small text-muted"> <p class="small text-muted">
Maximum memory usage per task (set to 0 for unlimited) Maximum memory usage per task (<b>MB</b>)
</p> </p>
</div> </div>
</div> </div>

View File

@ -0,0 +1,119 @@
angular.module('createStack', [])
.controller('CreateStackController', ['$scope', '$state', '$document', 'StackService', 'CodeMirrorService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService',
function ($scope, $state, $document, StackService, CodeMirrorService, Authentication, Notifications, FormValidator, ResourceControlService) {
// Store the editor content when switching builder methods
var editorContent = '';
var editorEnabled = true;
$scope.formValues = {
Name: '',
StackFileContent: '# Define or paste the content of your docker-compose file here',
StackFile: null,
RepositoryURL: '',
RepositoryPath: 'docker-compose.yml',
AccessControlData: new AccessControlFormData()
};
$scope.state = {
Method: 'editor',
formValidationError: ''
};
function validateForm(accessControlData, isAdmin) {
$scope.state.formValidationError = '';
var error = '';
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
if (error) {
$scope.state.formValidationError = error;
return false;
}
return true;
}
function createStack(name) {
var method = $scope.state.Method;
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);
} else if (method === 'upload') {
var stackFile = $scope.formValues.StackFile;
return StackService.createStackFromFileUpload(name, stackFile);
} else if (method === 'repository') {
var gitRepository = $scope.formValues.RepositoryURL;
var pathInRepository = $scope.formValues.RepositoryPath;
return StackService.createStackFromGitRepository(name, gitRepository, pathInRepository);
}
}
$scope.deployStack = function () {
$('#createResourceSpinner').show();
var name = $scope.formValues.Name;
var accessControlData = $scope.formValues.AccessControlData;
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true : false;
var userId = userDetails.ID;
if (!validateForm(accessControlData, isAdmin)) {
$('#createResourceSpinner').hide();
return;
}
createStack(name)
.then(function success(data) {
Notifications.success('Stack successfully deployed');
})
.catch(function error(err) {
Notifications.warning('Deployment error', err.err.data.err);
})
.then(function success(data) {
return ResourceControlService.applyResourceControl('stack', name, userId, accessControlData, []);
})
.then(function success() {
$state.go('stacks');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to apply resource control on the stack');
})
.finally(function final() {
$('#createResourceSpinner').hide();
});
};
function enableEditor(value) {
$document.ready(function() {
var webEditorElement = $document[0].getElementById('web-editor');
if (webEditorElement) {
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement);
if (value) {
$scope.editor.setValue(value);
}
}
});
}
$scope.toggleEditor = function() {
if (!editorEnabled) {
enableEditor(editorContent);
editorEnabled = true;
}
};
$scope.saveEditorContent = function() {
editorContent = $scope.editor.getValue();
editorEnabled = false;
};
function initView() {
enableEditor();
}
initView();
}]);

View File

@ -0,0 +1,156 @@
<rd-header>
<rd-header-title title="Create stack">
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="display: none;"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="stacks">Stacks</a> > Add stack
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal">
<!-- name-input -->
<div class="form-group">
<label for="stack_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="stack_name" placeholder="e.g. myStack" auto-focus>
</div>
</div>
<!-- !name-input -->
<div class="form-group">
<span class="col-sm-12 text-muted small">
This stack will be deployed using the equivalent of the <code>docker stack deploy</code> command.
</span>
</div>
<!-- build-method -->
<div class="col-sm-12 form-section-title">
Build method
</div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="state.Method" value="editor" ng-click="toggleEditor(state.Method)">
<label for="method_editor">
<div class="boxselector_header">
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
Web editor
</div>
<p>Use our Web editor</p>
</label>
</div>
<div>
<input type="radio" id="method_upload" ng-model="state.Method" value="upload" ng-click="saveEditorContent()">
<label for="method_upload">
<div class="boxselector_header">
<i class="fa fa-upload" aria-hidden="true" style="margin-right: 2px;"></i>
Upload
</div>
<p>Upload from your computer</p>
</label>
</div>
<div>
<input type="radio" id="method_repository" ng-model="state.Method" value="repository" ng-click="saveEditorContent()">
<label for="method_repository">
<div class="boxselector_header">
<i class="fa fa-git" aria-hidden="true" style="margin-right: 2px;"></i>
Repository
</div>
<p>Use a git repository</p>
</label>
</div>
</div>
</div>
<!-- !build-method -->
<!-- web-editor -->
<div ng-if="state.Method === 'editor'">
<div class="col-sm-12 form-section-title">
Web editor
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can get more information about Compose file format in the <a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<textarea id="web-editor" class="form-control" ng-model="formValues.StackFileContent" placeholder='version: "3"'></textarea>
</div>
</div>
</div>
<!-- !web-editor -->
<!-- upload -->
<div ng-if="state.Method === 'upload'">
<div class="col-sm-12 form-section-title">
Upload
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can upload a Compose file from your computer.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<button class="btn btn-sm btn-primary" ngf-select ng-model="formValues.StackFile">Select file</button>
<span style="margin-left: 5px;">
{{ formValues.StackFile.name }}
<i class="fa fa-times red-icon" ng-if="!formValues.StackFile" aria-hidden="true"></i>
</span>
</div>
</div>
</div>
<!-- !upload -->
<!-- repository -->
<div ng-if="state.Method === 'repository'">
<div class="col-sm-12 form-section-title">
Git repository
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can use the URL of a public git repository.
</span>
</div>
<div class="form-group">
<label for="stack_repository_url" class="col-sm-2 control-label text-left">Repository URL</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="formValues.RepositoryURL" id="stack_repository_url" placeholder="https://github.com/portainer/portainer-compose">
</div>
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
Indicate the path to the Compose file from the root of your repository.
</span>
</div>
<div class="form-group">
<label for="stack_repository_path" class="col-sm-2 control-label text-left">Compose path</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="formValues.RepositoryPath" id="stack_repository_path" placeholder="docker-compose.yml">
</div>
</div>
</div>
<!-- !repository -->
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<!-- 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="(state.Method === 'editor' && !formValues.StackFileContent)
|| (state.Method === 'upload' && !formValues.StackFile)
|| (state.Method === 'repository' && (!formValues.RepositoryURL || !formValues.RepositoryPath))
|| !formValues.Name" ng-click="deployStack()">Deploy the stack</button>
<a type="button" class="btn btn-default btn-sm" ui-sref="stacks">Cancel</a>
<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>
</div>

View File

@ -85,6 +85,32 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-6" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<a ui-sref="stacks">
<rd-widget>
<rd-widget-body>
<div class="widget-icon blue pull-left">
<i class="fa fa-th-list"></i>
</div>
<div class="title">{{ stackCount }}</div>
<div class="comment">Stacks</div>
</rd-widget-body>
</rd-widget>
</a>
</div>
<div class="col-xs-12 col-md-6" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<a ui-sref="services">
<rd-widget>
<rd-widget-body>
<div class="widget-icon blue pull-left">
<i class="fa fa-list-alt"></i>
</div>
<div class="title">{{ serviceCount }}</div>
<div class="comment">Services</div>
</rd-widget-body>
</rd-widget>
</a>
</div>
<div class="col-xs-12 col-md-6"> <div class="col-xs-12 col-md-6">
<a ui-sref="containers"> <a ui-sref="containers">
<rd-widget> <rd-widget>

View File

@ -1,6 +1,6 @@
angular.module('dashboard', []) angular.module('dashboard', [])
.controller('DashboardController', ['$scope', '$q', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'SystemService', 'Notifications', .controller('DashboardController', ['$scope', '$q', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'SystemService', 'ServiceService', 'StackService', 'Notifications',
function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, SystemService, Notifications) { function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, SystemService, ServiceService, StackService, Notifications) {
$scope.containerData = { $scope.containerData = {
total: 0 total: 0
@ -15,6 +15,9 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System
total: 0 total: 0
}; };
$scope.serviceCount = 0;
$scope.stackCount = 0;
function prepareContainerData(d) { function prepareContainerData(d) {
var running = 0; var running = 0;
var stopped = 0; var stopped = 0;
@ -63,18 +66,25 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System
function initView() { function initView() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
$q.all([ $q.all([
Container.query({all: 1}).$promise, Container.query({all: 1}).$promise,
Image.query({}).$promise, Image.query({}).$promise,
Volume.query({}).$promise, Volume.query({}).$promise,
Network.query({}).$promise, Network.query({}).$promise,
SystemService.info() SystemService.info(),
endpointProvider === 'DOCKER_SWARM_MODE' ? ServiceService.services() : [],
endpointProvider === 'DOCKER_SWARM_MODE' ? StackService.stacks(true) : []
]).then(function (d) { ]).then(function (d) {
prepareContainerData(d[0]); prepareContainerData(d[0]);
prepareImageData(d[1]); prepareImageData(d[1]);
prepareVolumeData(d[2]); prepareVolumeData(d[2]);
prepareNetworkData(d[3]); prepareNetworkData(d[3]);
prepareInfoData(d[4]); prepareInfoData(d[4]);
$scope.serviceCount = d[5].length;
$scope.stackCount = d[6].length;
$('#loadingViewSpinner').hide(); $('#loadingViewSpinner').hide();
}, function(e) { }, function(e) {
$('#loadingViewSpinner').hide(); $('#loadingViewSpinner').hide();

View File

@ -1,6 +1,6 @@
angular.module('endpoint', []) angular.module('endpoint', [])
.controller('EndpointController', ['$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'Notifications', .controller('EndpointController', ['$scope', '$state', '$transition$', '$filter', 'EndpointService', 'Notifications',
function ($scope, $state, $stateParams, $filter, EndpointService, Notifications) { function ($scope, $state, $transition$, $filter, EndpointService, Notifications) {
if (!$scope.applicationState.application.endpointManagement) { if (!$scope.applicationState.application.endpointManagement) {
$state.go('endpoints'); $state.go('endpoints');
@ -51,17 +51,16 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Notifications)
function initView() { function initView() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
EndpointService.endpoint($stateParams.id) EndpointService.endpoint($transition$.params().id)
.then(function success(data) { .then(function success(data) {
var endpoint = data; var endpoint = data;
endpoint.URL = $filter('stripprotocol')(endpoint.URL);
$scope.endpoint = endpoint;
if (endpoint.URL.indexOf('unix://') === 0) { if (endpoint.URL.indexOf('unix://') === 0) {
$scope.endpointType = 'local'; $scope.endpointType = 'local';
} else { } else {
$scope.endpointType = 'remote'; $scope.endpointType = 'remote';
} }
endpoint.URL = $filter('stripprotocol')(endpoint.URL);
$scope.endpoint = endpoint;
}) })
.catch(function error(err) { .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoint details'); Notifications.error('Failure', err, 'Unable to retrieve endpoint details');

View File

@ -1,14 +1,14 @@
angular.module('endpointAccess', []) angular.module('endpointAccess', [])
.controller('EndpointAccessController', ['$scope', '$stateParams', 'EndpointService', 'Notifications', .controller('EndpointAccessController', ['$scope', '$transition$', 'EndpointService', 'Notifications',
function ($scope, $stateParams, EndpointService, Notifications) { function ($scope, $transition$, EndpointService, Notifications) {
$scope.updateAccess = function(authorizedUsers, authorizedTeams) { $scope.updateAccess = function(authorizedUsers, authorizedTeams) {
return EndpointService.updateAccess($stateParams.id, authorizedUsers, authorizedTeams); return EndpointService.updateAccess($transition$.params().id, authorizedUsers, authorizedTeams);
}; };
function initView() { function initView() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
EndpointService.endpoint($stateParams.id) EndpointService.endpoint($transition$.params().id)
.then(function success(data) { .then(function success(data) {
$scope.endpoint = data; $scope.endpoint = data;
}) })

View File

@ -1,6 +1,6 @@
angular.module('image', []) angular.module('image', [])
.controller('ImageController', ['$q', '$scope', '$stateParams', '$state', '$timeout', 'ImageService', 'RegistryService', 'Notifications', .controller('ImageController', ['$q', '$scope', '$transition$', '$state', '$timeout', 'ImageService', 'RegistryService', 'Notifications',
function ($q, $scope, $stateParams, $state, $timeout, ImageService, RegistryService, Notifications) { function ($q, $scope, $transition$, $state, $timeout, ImageService, RegistryService, Notifications) {
$scope.formValues = { $scope.formValues = {
Image: '', Image: '',
Registry: '' Registry: ''
@ -25,10 +25,10 @@ function ($q, $scope, $stateParams, $state, $timeout, ImageService, RegistryServ
var image = $scope.formValues.Image; var image = $scope.formValues.Image;
var registry = $scope.formValues.Registry; var registry = $scope.formValues.Registry;
ImageService.tagImage($stateParams.id, image, registry.URL) ImageService.tagImage($transition$.params().id, image, registry.URL)
.then(function success(data) { .then(function success(data) {
Notifications.success('Image successfully tagged'); Notifications.success('Image successfully tagged');
$state.go('image', {id: $stateParams.id}, {reload: true}); $state.go('image', {id: $transition$.params().id}, {reload: true});
}) })
.catch(function error(err) { .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to tag image'); Notifications.error('Failure', err, 'Unable to tag image');
@ -83,7 +83,7 @@ function ($q, $scope, $stateParams, $state, $timeout, ImageService, RegistryServ
$state.go('images', {}, {reload: true}); $state.go('images', {}, {reload: true});
} else { } else {
Notifications.success('Tag successfully deleted', repository); Notifications.success('Tag successfully deleted', repository);
$state.go('image', {id: $stateParams.id}, {reload: true}); $state.go('image', {id: $transition$.params().id}, {reload: true});
} }
}) })
.catch(function error(err) { .catch(function error(err) {
@ -113,8 +113,8 @@ function ($q, $scope, $stateParams, $state, $timeout, ImageService, RegistryServ
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
var endpointProvider = $scope.applicationState.endpoint.mode.provider; var endpointProvider = $scope.applicationState.endpoint.mode.provider;
$q.all({ $q.all({
image: ImageService.image($stateParams.id), image: ImageService.image($transition$.params().id),
history: endpointProvider !== 'VMWARE_VIC' ? ImageService.history($stateParams.id) : [] history: endpointProvider !== 'VMWARE_VIC' ? ImageService.history($transition$.params().id) : []
}) })
.then(function success(data) { .then(function success(data) {
$scope.image = data.image; $scope.image = data.image;

View File

@ -1,16 +1,16 @@
angular.module('network', []) angular.module('network', [])
.controller('NetworkController', ['$scope', '$state', '$stateParams', '$filter', 'Network', 'NetworkService', 'Container', 'ContainerHelper', 'Notifications', .controller('NetworkController', ['$scope', '$state', '$transition$', '$filter', 'Network', 'NetworkService', 'Container', 'ContainerHelper', 'Notifications',
function ($scope, $state, $stateParams, $filter, Network, NetworkService, Container, ContainerHelper, Notifications) { function ($scope, $state, $transition$, $filter, Network, NetworkService, Container, ContainerHelper, Notifications) {
$scope.removeNetwork = function removeNetwork(networkId) { $scope.removeNetwork = function removeNetwork(networkId) {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
Network.remove({id: $stateParams.id}, function (d) { Network.remove({id: $transition$.params().id}, function (d) {
if (d.message) { if (d.message) {
$('#loadingViewSpinner').hide(); $('#loadingViewSpinner').hide();
Notifications.error('Error', d, 'Unable to remove network'); Notifications.error('Error', d, 'Unable to remove network');
} else { } else {
$('#loadingViewSpinner').hide(); $('#loadingViewSpinner').hide();
Notifications.success('Network removed', $stateParams.id); Notifications.success('Network removed', $transition$.params().id);
$state.go('networks', {}); $state.go('networks', {});
} }
}, function (e) { }, function (e) {
@ -21,13 +21,13 @@ function ($scope, $state, $stateParams, $filter, Network, NetworkService, Contai
$scope.containerLeaveNetwork = function containerLeaveNetwork(network, containerId) { $scope.containerLeaveNetwork = function containerLeaveNetwork(network, containerId) {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
Network.disconnect({id: $stateParams.id}, { Container: containerId, Force: false }, function (d) { Network.disconnect({id: $transition$.params().id}, { Container: containerId, Force: false }, function (d) {
if (d.message) { if (d.message) {
$('#loadingViewSpinner').hide(); $('#loadingViewSpinner').hide();
Notifications.error('Error', d, 'Unable to disconnect container from network'); Notifications.error('Error', d, 'Unable to disconnect container from network');
} else { } else {
$('#loadingViewSpinner').hide(); $('#loadingViewSpinner').hide();
Notifications.success('Container left network', $stateParams.id); Notifications.success('Container left network', $transition$.params().id);
$state.go('network', {id: network.Id}, {reload: true}); $state.go('network', {id: network.Id}, {reload: true});
} }
}, function (e) { }, function (e) {
@ -68,7 +68,7 @@ function ($scope, $state, $stateParams, $filter, Network, NetworkService, Contai
}); });
} else { } else {
Container.query({ Container.query({
filters: {network: [$stateParams.id]} filters: {network: [$transition$.params().id]}
}, function success(data) { }, function success(data) {
filterContainersInNetwork(network, data); filterContainersInNetwork(network, data);
$('#loadingViewSpinner').hide(); $('#loadingViewSpinner').hide();
@ -82,7 +82,7 @@ function ($scope, $state, $stateParams, $filter, Network, NetworkService, Contai
function initView() { function initView() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
NetworkService.network($stateParams.id) NetworkService.network($transition$.params().id)
.then(function success(data) { .then(function success(data) {
$scope.network = data; $scope.network = data;
var endpointProvider = $scope.applicationState.endpoint.mode.provider; var endpointProvider = $scope.applicationState.endpoint.mode.provider;

View File

@ -48,10 +48,10 @@
</a> </a>
</th> </th>
<th> <th>
<a ng-click="order('Id')"> <a ng-click="order('StackName')">
Id Stack
<span ng-show="sortType == 'Id' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Id' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th> <th>
@ -101,8 +101,8 @@
<tbody> <tbody>
<tr dir-paginate="network in ( state.filteredNetworks = (networks | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))"> <tr dir-paginate="network in ( state.filteredNetworks = (networks | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="network.Checked" ng-change="selectItem(network)"/></td> <td><input type="checkbox" ng-model="network.Checked" ng-change="selectItem(network)"/></td>
<td><a ui-sref="network({id: network.Id})">{{ network.Name|truncate:40}}</a></td> <td><a ui-sref="network({id: network.Id})">{{ network.Name | truncate:40 }}</a></td>
<td class="monospaced">{{ network.Id|truncate:20 }}</td> <td>{{ network.StackName ? network.StackName : '-' }}</td>
<td>{{ network.Scope }}</td> <td>{{ network.Scope }}</td>
<td>{{ network.Driver }}</td> <td>{{ network.Driver }}</td>
<td>{{ network.IPAM.Driver }}</td> <td>{{ network.IPAM.Driver }}</td>

View File

@ -1,8 +1,8 @@
// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. // @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services.
// See app/components/templates/templatesController.js as a reference. // See app/components/templates/templatesController.js as a reference.
angular.module('node', []) angular.module('node', [])
.controller('NodeController', ['$scope', '$state', '$stateParams', 'LabelHelper', 'Node', 'NodeHelper', 'Task', 'Pagination', 'Notifications', .controller('NodeController', ['$scope', '$state', '$transition$', 'LabelHelper', 'Node', 'NodeHelper', 'Task', 'Pagination', 'Notifications',
function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pagination, Notifications) { function ($scope, $state, $transition$, LabelHelper, Node, NodeHelper, Task, Pagination, Notifications) {
$scope.state = {}; $scope.state = {};
$scope.state.pagination_count = Pagination.getPaginationCount('node_tasks'); $scope.state.pagination_count = Pagination.getPaginationCount('node_tasks');
@ -80,7 +80,7 @@ function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pag
function loadNodeAndTasks() { function loadNodeAndTasks() {
$scope.loading = true; $scope.loading = true;
if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') {
Node.get({ id: $stateParams.id}, function(d) { Node.get({ id: $transition$.params().id}, function(d) {
if (d.message) { if (d.message) {
Notifications.error('Failure', e, 'Unable to inspect the node'); Notifications.error('Failure', e, 'Unable to inspect the node');
} else { } else {

View File

@ -1,6 +1,6 @@
angular.module('registry', []) angular.module('registry', [])
.controller('RegistryController', ['$scope', '$state', '$stateParams', '$filter', 'RegistryService', 'Notifications', .controller('RegistryController', ['$scope', '$state', '$transition$', '$filter', 'RegistryService', 'Notifications',
function ($scope, $state, $stateParams, $filter, RegistryService, Notifications) { function ($scope, $state, $transition$, $filter, RegistryService, Notifications) {
$scope.updateRegistry = function() { $scope.updateRegistry = function() {
$('#updateRegistrySpinner').show(); $('#updateRegistrySpinner').show();
@ -20,7 +20,7 @@ function ($scope, $state, $stateParams, $filter, RegistryService, Notifications)
function initView() { function initView() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
var registryID = $stateParams.id; var registryID = $transition$.params().id;
RegistryService.registry(registryID) RegistryService.registry(registryID)
.then(function success(data) { .then(function success(data) {
$scope.registry = data; $scope.registry = data;

View File

@ -1,14 +1,14 @@
angular.module('registryAccess', []) angular.module('registryAccess', [])
.controller('RegistryAccessController', ['$scope', '$stateParams', 'RegistryService', 'Notifications', .controller('RegistryAccessController', ['$scope', '$transition$', 'RegistryService', 'Notifications',
function ($scope, $stateParams, RegistryService, Notifications) { function ($scope, $transition$, RegistryService, Notifications) {
$scope.updateAccess = function(authorizedUsers, authorizedTeams) { $scope.updateAccess = function(authorizedUsers, authorizedTeams) {
return RegistryService.updateAccess($stateParams.id, authorizedUsers, authorizedTeams); return RegistryService.updateAccess($transition$.params().id, authorizedUsers, authorizedTeams);
}; };
function initView() { function initView() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
RegistryService.registry($stateParams.id) RegistryService.registry($transition$.params().id)
.then(function success(data) { .then(function success(data) {
$scope.registry = data; $scope.registry = data;
}) })

View File

@ -1,6 +1,6 @@
angular.module('secret', []) angular.module('secret', [])
.controller('SecretController', ['$scope', '$stateParams', '$state', 'SecretService', 'Notifications', .controller('SecretController', ['$scope', '$transition$', '$state', 'SecretService', 'Notifications',
function ($scope, $stateParams, $state, SecretService, Notifications) { function ($scope, $transition$, $state, SecretService, Notifications) {
$scope.removeSecret = function removeSecret(secretId) { $scope.removeSecret = function removeSecret(secretId) {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
@ -19,7 +19,7 @@ function ($scope, $stateParams, $state, SecretService, Notifications) {
function initView() { function initView() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
SecretService.secret($stateParams.id) SecretService.secret($transition$.params().id)
.then(function success(data) { .then(function success(data) {
$scope.secret = data; $scope.secret = data;
}) })

View File

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

View File

@ -12,6 +12,11 @@
</select> </select>
</div> </div>
</rd-widget-header> </rd-widget-header>
<rd-widget-taskbar classes="col-lg-12">
<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"> <rd-widget-body classes="no-padding">
<table class="table"> <table class="table">
<thead> <thead>
@ -48,7 +53,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr dir-paginate="task in (filteredTasks = ( tasks | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))"> <tr dir-paginate="task in (filteredTasks = ( tasks | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><a ui-sref="task({ id: task.Id })" class="monospaced">{{ task.Id }}</a></td> <td><a ui-sref="task({ id: task.Id })" class="monospaced">{{ task.Id }}</a></td>
<td><span class="label label-{{ task.Status.State|taskstatusbadge }}">{{ task.Status.State }}</span></td> <td><span class="label label-{{ task.Status.State|taskstatusbadge }}">{{ task.Status.State }}</span></td>
<td ng-if="service.Mode !== 'global'">{{ task.Slot }}</td> <td ng-if="service.Mode !== 'global'">{{ task.Slot }}</td>

View File

@ -1,6 +1,6 @@
angular.module('service', []) angular.module('service', [])
.controller('ServiceController', ['$q', '$scope', '$stateParams', '$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', 'SecretService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService',
function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, ServiceService, SecretService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, Pagination, ModalService) { function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, SecretService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, Pagination, ModalService) {
$scope.state = {}; $scope.state = {};
$scope.state.pagination_count = Pagination.getPaginationCount('service_tasks'); $scope.state.pagination_count = Pagination.getPaginationCount('service_tasks');
@ -307,7 +307,7 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll,
function initView() { function initView() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
var apiVersion = $scope.applicationState.endpoint.apiVersion; var apiVersion = $scope.applicationState.endpoint.apiVersion;
ServiceService.service($stateParams.id) ServiceService.service($transition$.params().id)
.then(function success(data) { .then(function success(data) {
var service = data; var service = data;
$scope.isUpdating = $scope.lastVersion >= service.Version; $scope.isUpdating = $scope.lastVersion >= service.Version;
@ -321,7 +321,7 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll,
originalService = angular.copy(service); originalService = angular.copy(service);
return $q.all({ return $q.all({
tasks: TaskService.serviceTasks(service.Name), tasks: TaskService.tasks({ service: [service.Name] }),
nodes: NodeService.nodes(), nodes: NodeService.nodes(),
secrets: apiVersion >= 1.25 ? SecretService.secrets() : [] secrets: apiVersion >= 1.25 ? SecretService.secrets() : []
}); });

View File

@ -1,6 +1,6 @@
angular.module('serviceLogs', []) angular.module('serviceLogs', [])
.controller('ServiceLogsController', ['$scope', '$stateParams', '$anchorScroll', 'ServiceLogs', 'Service', .controller('ServiceLogsController', ['$scope', '$transition$', '$anchorScroll', 'ServiceLogs', 'Service',
function ($scope, $stateParams, $anchorScroll, ServiceLogs, Service) { function ($scope, $transition$, $anchorScroll, ServiceLogs, Service) {
$scope.state = {}; $scope.state = {};
$scope.state.displayTimestampsOut = false; $scope.state.displayTimestampsOut = false;
$scope.state.displayTimestampsErr = false; $scope.state.displayTimestampsErr = false;
@ -16,7 +16,7 @@ function ($scope, $stateParams, $anchorScroll, ServiceLogs, Service) {
} }
function getLogsStderr() { function getLogsStderr() {
ServiceLogs.get($stateParams.id, { ServiceLogs.get($transition$.params().id, {
stdout: 0, stdout: 0,
stderr: 1, stderr: 1,
timestamps: $scope.state.displayTimestampsErr, timestamps: $scope.state.displayTimestampsErr,
@ -32,7 +32,7 @@ function ($scope, $stateParams, $anchorScroll, ServiceLogs, Service) {
} }
function getLogsStdout() { function getLogsStdout() {
ServiceLogs.get($stateParams.id, { ServiceLogs.get($transition$.params().id, {
stdout: 1, stdout: 1,
stderr: 0, stderr: 0,
timestamps: $scope.state.displayTimestampsOut, timestamps: $scope.state.displayTimestampsOut,
@ -49,7 +49,7 @@ function ($scope, $stateParams, $anchorScroll, ServiceLogs, Service) {
function getService() { function getService() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
Service.get({id: $stateParams.id}, function (d) { Service.get({id: $transition$.params().id}, function (d) {
$scope.service = d; $scope.service = d;
$('#loadingViewSpinner').hide(); $('#loadingViewSpinner').hide();
}, function (e) { }, function (e) {

View File

@ -38,42 +38,49 @@
<thead> <thead>
<th></th> <th></th>
<th> <th>
<a ui-sref="services" ng-click="order('Name')"> <a ng-click="order('Name')">
Name Name
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th> <th>
<a ui-sref="services" ng-click="order('Image')"> <a ng-click="order('StackName')">
Stack
<span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('Image')">
Image Image
<span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <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 == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th> <th>
<a ui-sref="services" ng-click="order('Mode')"> <a ng-click="order('Mode')">
Scheduling mode Scheduling mode
<span ng-show="sortType == 'Mode' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Mode' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Mode' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Mode' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th> <th>
<a ui-sref="services" ng-click="order('Ports')"> <a ng-click="order('Ports')">
Published Ports Published Ports
<span ng-show="sortType == 'Ports' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Ports' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Ports' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Ports' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th> <th>
<a ui-sref="services" ng-click="order('UpdatedAt')"> <a ng-click="order('UpdatedAt')">
Updated at Updated at
<span ng-show="sortType == 'UpdatedAt' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'UpdatedAt' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'UpdatedAt' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'UpdatedAt' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th ng-if="applicationState.application.authentication"> <th ng-if="applicationState.application.authentication">
<a ui-sref="services" ng-click="order('ResourceControl.Ownership')"> <a ng-click="order('ResourceControl.Ownership')">
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-down"></span>
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
@ -84,6 +91,7 @@
<tr dir-paginate="service in (state.filteredServices = ( services | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))"> <tr dir-paginate="service in (state.filteredServices = ( services | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="service.Checked" ng-change="selectItem(service)"/></td> <td><input type="checkbox" ng-model="service.Checked" ng-change="selectItem(service)"/></td>
<td><a ui-sref="service({id: service.Id})">{{ service.Name }}</a></td> <td><a ui-sref="service({id: service.Id})">{{ service.Name }}</a></td>
<td>{{ service.StackName ? service.StackName : '-' }}</td>
<td>{{ service.Image | hideshasum }}</td> <td>{{ service.Image | hideshasum }}</td>
<td> <td>
{{ service.Mode }} {{ service.Mode }}

View File

@ -1,6 +1,6 @@
angular.module('services', []) angular.module('services', [])
.controller('ServicesController', ['$q', '$scope', '$stateParams', '$state', 'Service', 'ServiceService', 'ServiceHelper', 'Notifications', 'Pagination', 'Task', 'Node', 'NodeHelper', 'ModalService', 'ResourceControlService', .controller('ServicesController', ['$q', '$scope', '$transition$', '$state', 'Service', 'ServiceService', 'ServiceHelper', 'Notifications', 'Pagination', 'Task', 'Node', 'NodeHelper', 'ModalService', 'ResourceControlService',
function ($q, $scope, $stateParams, $state, Service, ServiceService, ServiceHelper, Notifications, Pagination, Task, Node, NodeHelper, ModalService, ResourceControlService) { function ($q, $scope, $transition$, $state, Service, ServiceService, ServiceHelper, Notifications, Pagination, Task, Node, NodeHelper, ModalService, ResourceControlService) {
$scope.state = {}; $scope.state = {};
$scope.state.selectedItemCount = 0; $scope.state.selectedItemCount = 0;
$scope.state.pagination_count = Pagination.getPaginationCount('services'); $scope.state.pagination_count = Pagination.getPaginationCount('services');

View File

@ -11,6 +11,33 @@
<rd-widget-header icon="fa-cogs" title="Application settings"></rd-widget-header> <rd-widget-header icon="fa-cogs" title="Application settings"></rd-widget-header>
<rd-widget-body> <rd-widget-body>
<form class="form-horizontal"> <form class="form-horizontal">
<!-- security -->
<div class="col-sm-12 form-section-title">
Security
</div>
<div class="form-group">
<div class="col-sm-12">
<label for="toggle_allowbindmounts" class="control-label text-left">
Disable bind mounts for non-administrators
<portainer-tooltip position="bottom" message="When enabled, regular users will not be able to use bind mounts when creating containers."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" name="toggle_allowbindmounts" ng-model="formValues.restrictBindMounts"><i></i>
</label>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<label for="toggle_allowbindmounts" class="control-label text-left">
Disable privileged mode for non-administrators
<portainer-tooltip position="bottom" message="When enabled, regular users will not be able to use privileged mode when creating containers."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" name="toggle_allowbindmounts" ng-model="formValues.restrictPrivilegedMode"><i></i>
</label>
</div>
</div>
<!-- security -->
<!-- logo --> <!-- logo -->
<div class="col-sm-12 form-section-title"> <div class="col-sm-12 form-section-title">
Logo Logo

View File

@ -6,6 +6,8 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_
customLogo: false, customLogo: false,
customTemplates: false, customTemplates: false,
externalContributions: false, externalContributions: false,
restrictBindMounts: false,
restrictPrivilegedMode: false,
labelName: '', labelName: '',
labelValue: '' labelValue: ''
}; };
@ -39,6 +41,8 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_
settings.TemplatesURL = DEFAULT_TEMPLATES_URL; settings.TemplatesURL = DEFAULT_TEMPLATES_URL;
} }
settings.DisplayExternalContributors = !$scope.formValues.externalContributions; settings.DisplayExternalContributors = !$scope.formValues.externalContributions;
settings.AllowBindMountsForRegularUsers = !$scope.formValues.restrictBindMounts;
settings.AllowPrivilegedModeForRegularUsers = !$scope.formValues.restrictPrivilegedMode;
updateSettings(settings, false); updateSettings(settings, false);
}; };
@ -81,6 +85,8 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_
$scope.formValues.customTemplates = true; $scope.formValues.customTemplates = true;
} }
$scope.formValues.externalContributions = !settings.DisplayExternalContributors; $scope.formValues.externalContributions = !settings.DisplayExternalContributors;
$scope.formValues.restrictBindMounts = !settings.AllowBindMountsForRegularUsers;
$scope.formValues.restrictPrivilegedMode = !settings.AllowPrivilegedModeForRegularUsers;
}) })
.catch(function error(err) { .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve application settings'); Notifications.error('Failure', err, 'Unable to retrieve application settings');

View File

@ -25,6 +25,9 @@
<a ui-sref="templates_linuxserver" ui-sref-active="active">LinuxServer.io</a> <a ui-sref="templates_linuxserver" ui-sref-active="active">LinuxServer.io</a>
</div> </div>
</li> </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="stacks" ui-sref-active="active">Stacks <span class="menu-icon fa fa-th-list"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'"> <li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="services" ui-sref-active="active">Services <span class="menu-icon fa fa-list-alt"></span></a> <a ui-sref="services" ui-sref-active="active">Services <span class="menu-icon fa fa-list-alt"></span></a>
</li> </li>
@ -75,11 +78,9 @@
</li> </li>
</ul> </ul>
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="col-xs-12"> <div class="col-sm-12">
<a href="https://github.com/portainer/portainer" target="_blank"> <img src="images/logo_small.png" class="img-responsive logo" alt="Portainer">
<i class="fa fa-github" aria-hidden="true"></i> <span class="version">{{ uiVersion }}</span>
Portainer {{ uiVersion }}
</a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,54 @@
<rd-header>
<rd-header-title title="Stack details">
<a data-toggle="tooltip" title="Refresh" ui-sref="stack({id: stack.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="stacks">Stacks</a> > <a ui-sref="stack({id: stack.Id})">{{ stack.Name }}</a>
</rd-header-content>
</rd-header>
<!-- access-control-panel -->
<por-access-control-panel
ng-if="stack && applicationState.application.authentication"
resource-id="stack.Name"
resource-control="stack.ResourceControl"
resource-type="'stack'">
</por-access-control-panel>
<!-- !access-control-panel -->
<por-service-list services="services" nodes="nodes"></por-service-list>
<por-task-list tasks="tasks" nodes="nodes"></por-task-list>
<div class="row" ng-if="stackFileContent">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-pencil" title="Stack editor"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can get more information about Compose file format in the <a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<textarea id="web-editor" class="form-control" ng-model="stackFileContent" placeholder='version: "3"'></textarea>
</div>
</div>
<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-sm btn-primary" ng-click="deployStack()">Update stack</button>
<i id="createResourceSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,78 @@
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) {
$scope.deployStack = function () {
$('#createResourceSpinner').show();
// 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();
StackService.updateStack($scope.stack.Id, stackFile)
.then(function success(data) {
Notifications.success('Stack successfully deployed');
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create stack');
})
.finally(function final() {
$('#createResourceSpinner').hide();
});
};
function initView() {
$('#loadingViewSpinner').show();
var stackId = $stateParams.id;
StackService.stack(stackId)
.then(function success(data) {
var stack = data;
$scope.stack = stack;
var serviceFilters = {
label: ['com.docker.stack.namespace=' + stack.Name]
};
return $q.all({
stackFile: StackService.getStackFile(stackId),
services: ServiceService.services(serviceFilters),
tasks: TaskService.tasks(serviceFilters),
nodes: NodeService.nodes()
});
})
.then(function success(data) {
$scope.stackFileContent = data.stackFile;
$document.ready(function() {
var webEditorElement = $document[0].getElementById('web-editor');
if (webEditorElement) {
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement);
}
});
$scope.nodes = data.nodes;
var services = data.services;
var tasks = data.tasks;
$scope.tasks = tasks;
for (var i = 0; i < services.length; i++) {
var service = services[i];
ServiceHelper.associateTasksToService(service, tasks);
}
$scope.services = services;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve tasks details');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
initView();
}]);

View File

@ -0,0 +1,120 @@
<rd-header>
<rd-header-title title="Stacks list">
<a data-toggle="tooltip" title="Refresh" ui-sref="stacks" 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>Stacks</rd-header-content>
</rd-header>
<div class="row" ng-if="state.DisplayInformationPanel">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-info-circle" title="Information"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<span class="col-sm-12 text-muted small">
Stacks marked with the <i class="fa fa-exclamation-circle orange-icon" aria-hidden="true"></i> icon are external stacks that were created outside of Portainer. You'll not be able to execute any actions against these stacks.
</span>
</div>
<div class="col-sm-12 form-section-title">
Filters
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Display external stacks
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="state.DisplayExternalStacks"><i></i>
</label>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-th-list" title="Stacks">
<div class="pull-right">
Items per page:
<select ng-model="state.pagination_count" ng-change="changePaginationCount()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</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.stack"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add stack</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 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="stack in (state.filteredStacks = ( stacks | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))" ng-if="state.DisplayExternalStacks || (!state.DisplayExternalStacks && !stack.External)">
<td><input type="checkbox" ng-model="stack.Checked" ng-change="selectItem(stack)" ng-disabled="!stack.Id"/></td>
<td>
<span ng-if="stack.Id">
<a ui-sref="stack({ id: stack.Id })">{{ stack.Name }}</a>
</span>
<span ng-if="!stack.Id">
{{ stack.Name }} <i class="fa fa-exclamation-circle orange-icon" aria-hidden="true"></i>
</span>
</td>
<td ng-if="applicationState.application.authentication">
<span>
<i ng-class="stack.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
{{ stack.ResourceControl.Ownership ? stack.ResourceControl.Ownership : stack.ResourceControl.Ownership = 'public' }}
</span>
</td>
</tr>
<tr ng-if="!stacks">
<td colspan="3" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="stacks.length === 0">
<td colspan="3" class="text-center text-muted">No stacks available.</td>
</tr>
</tbody>
</table>
<div ng-if="stacks" 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,103 @@
angular.module('stacks', [])
.controller('StacksController', ['$scope', 'Notifications', 'Pagination', 'StackService', 'ModalService',
function ($scope, Notifications, Pagination, StackService, ModalService) {
$scope.state = {};
$scope.state.selectedItemCount = 0;
$scope.state.pagination_count = Pagination.getPaginationCount('stacks');
$scope.sortType = 'Name';
$scope.sortReverse = false;
$scope.state.DisplayInformationPanel = false;
$scope.state.DisplayExternalStacks = true;
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('stacks', $scope.state.pagination_count);
};
$scope.order = function (sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.selectItems = function (allSelected) {
angular.forEach($scope.state.filteredStacks, function (stack) {
if (stack.Id && stack.Checked !== allSelected) {
stack.Checked = allSelected;
$scope.selectItem(stack);
}
});
};
$scope.selectItem = function (item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
} else {
$scope.state.selectedItemCount--;
}
};
$scope.removeAction = function () {
ModalService.confirmDeletion(
'Do you want to remove the selected stack(s)? Associated services will be removed as well.',
function onConfirm(confirmed) {
if(!confirmed) { return; }
deleteSelectedStacks();
}
);
};
function deleteSelectedStacks() {
$('#loadingViewSpinner').show();
var counter = 0;
var complete = function () {
counter = counter - 1;
if (counter === 0) {
$('#loadingViewSpinner').hide();
}
};
angular.forEach($scope.stacks, function (stack) {
if (stack.Checked) {
counter = counter + 1;
StackService.remove(stack)
.then(function success() {
Notifications.success('Stack deleted', stack.Name);
var index = $scope.stacks.indexOf(stack);
$scope.stacks.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove stack ' + stack.Name);
})
.finally(function final() {
complete();
});
}
});
}
function initView() {
$('#loadingViewSpinner').show();
StackService.stacks(true)
.then(function success(data) {
var stacks = data;
for (var i = 0; i < stacks.length; i++) {
var stack = stacks[i];
if (stack.External) {
$scope.state.DisplayInformationPanel = true;
break;
}
}
$scope.stacks = stacks;
})
.catch(function error(err) {
$scope.stacks = [];
Notifications.error('Failure', err, 'Unable to retrieve stacks');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
initView();
}]);

View File

@ -1,10 +1,10 @@
angular.module('task', []) angular.module('task', [])
.controller('TaskController', ['$scope', '$stateParams', 'TaskService', 'Service', 'Notifications', .controller('TaskController', ['$scope', '$transition$', 'TaskService', 'Service', 'Notifications',
function ($scope, $stateParams, TaskService, Service, Notifications) { function ($scope, $transition$, TaskService, Service, Notifications) {
function initView() { function initView() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
TaskService.task($stateParams.id) TaskService.task($transition$.params().id)
.then(function success(data) { .then(function success(data) {
var task = data; var task = data;
$scope.task = task; $scope.task = task;

View File

@ -1,6 +1,6 @@
angular.module('team', []) angular.module('team', [])
.controller('TeamController', ['$q', '$scope', '$state', '$stateParams', 'TeamService', 'UserService', 'TeamMembershipService', 'ModalService', 'Notifications', 'Pagination', 'Authentication', .controller('TeamController', ['$q', '$scope', '$state', '$transition$', 'TeamService', 'UserService', 'TeamMembershipService', 'ModalService', 'Notifications', 'Pagination', 'Authentication',
function ($q, $scope, $state, $stateParams, TeamService, UserService, TeamMembershipService, ModalService, Notifications, Pagination, Authentication) { function ($q, $scope, $state, $transition$, TeamService, UserService, TeamMembershipService, ModalService, Notifications, Pagination, Authentication) {
$scope.state = { $scope.state = {
pagination_count_users: Pagination.getPaginationCount('team_available_users'), pagination_count_users: Pagination.getPaginationCount('team_available_users'),
@ -208,9 +208,9 @@ function ($q, $scope, $state, $stateParams, TeamService, UserService, TeamMember
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
$scope.isAdmin = Authentication.getUserDetails().role === 1 ? true: false; $scope.isAdmin = Authentication.getUserDetails().role === 1 ? true: false;
$q.all({ $q.all({
team: TeamService.team($stateParams.id), team: TeamService.team($transition$.params().id),
users: UserService.users(false), users: UserService.users(false),
memberships: TeamService.userMemberships($stateParams.id) memberships: TeamService.userMemberships($transition$.params().id)
}) })
.then(function success(data) { .then(function success(data) {
var users = data.users; var users = data.users;

View File

@ -154,7 +154,7 @@
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'auto'" ng-click="volume.name = ''">Auto</label> <label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'auto'" ng-click="volume.name = ''">Auto</label>
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label> <label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.name = ''">Bind</label> <label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.name = ''" ng-if="isAdmin || allowBindMounts">Bind</label>
</div> </div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeVolume($index)"> <button class="btn btn-sm btn-danger" type="button" ng-click="removeVolume($index)">
<i class="fa fa-trash" aria-hidden="true"></i> <i class="fa fa-trash" aria-hidden="true"></i>

View File

@ -1,10 +1,10 @@
angular.module('templates', []) angular.module('templates', [])
.controller('TemplatesController', ['$scope', '$q', '$state', '$stateParams', '$anchorScroll', '$filter', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', 'FormValidator', .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, $stateParams, $anchorScroll, $filter, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication, FormValidator) { function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication, FormValidator, SettingsService) {
$scope.state = { $scope.state = {
selectedTemplate: null, selectedTemplate: null,
showAdvancedOptions: false, showAdvancedOptions: false,
hideDescriptions: $stateParams.hide_descriptions, hideDescriptions: $transition$.params().hide_descriptions,
formValidationError: '', formValidationError: '',
filters: { filters: {
Categories: '!', Categories: '!',
@ -145,7 +145,7 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, ContainerSer
} }
function initTemplates() { function initTemplates() {
var templatesKey = $stateParams.key; var templatesKey = $transition$.params().key;
var provider = $scope.applicationState.endpoint.mode.provider; var provider = $scope.applicationState.endpoint.mode.provider;
var apiVersion = $scope.applicationState.endpoint.apiVersion; var apiVersion = $scope.applicationState.endpoint.apiVersion;
@ -157,7 +157,8 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, ContainerSer
provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE', provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE',
false, false,
provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25, provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25,
provider === 'DOCKER_SWARM') provider === 'DOCKER_SWARM'),
settings: SettingsService.publicSettings()
}) })
.then(function success(data) { .then(function success(data) {
$scope.templates = data.templates; $scope.templates = data.templates;
@ -171,6 +172,10 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, ContainerSer
var networks = data.networks; var networks = data.networks;
$scope.availableNetworks = networks; $scope.availableNetworks = networks;
$scope.globalNetworkCount = networks.length; $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) { .catch(function error(err) {
$scope.templates = []; $scope.templates = [];

View File

@ -1,6 +1,6 @@
angular.module('user', []) angular.module('user', [])
.controller('UserController', ['$q', '$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Notifications', 'SettingsService', .controller('UserController', ['$q', '$scope', '$state', '$transition$', 'UserService', 'ModalService', 'Notifications', 'SettingsService',
function ($q, $scope, $state, $stateParams, UserService, ModalService, Notifications, SettingsService) { function ($q, $scope, $state, $transition$, UserService, ModalService, Notifications, SettingsService) {
$scope.state = { $scope.state = {
updatePasswordError: '' updatePasswordError: ''
@ -72,7 +72,7 @@ function ($q, $scope, $state, $stateParams, UserService, ModalService, Notificat
function initView() { function initView() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
$q.all({ $q.all({
user: UserService.user($stateParams.id), user: UserService.user($transition$.params().id),
settings: SettingsService.publicSettings() settings: SettingsService.publicSettings()
}) })
.then(function success(data) { .then(function success(data) {

View File

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

View File

@ -9,11 +9,10 @@ function ($scope, $state, $sanitize, Authentication, UserService, Notifications,
$scope.updatePassword = function() { $scope.updatePassword = function() {
$scope.invalidPassword = false; $scope.invalidPassword = false;
var userID = Authentication.getUserDetails().ID;
var currentPassword = $sanitize($scope.formValues.currentPassword); var currentPassword = $sanitize($scope.formValues.currentPassword);
var newPassword = $sanitize($scope.formValues.newPassword); var newPassword = $sanitize($scope.formValues.newPassword);
UserService.updateUserPassword(userID, currentPassword, newPassword) UserService.updateUserPassword($scope.userID, currentPassword, newPassword)
.then(function success() { .then(function success() {
Notifications.success('Success', 'Password successfully updated'); Notifications.success('Success', 'Password successfully updated');
$state.reload(); $state.reload();
@ -28,6 +27,7 @@ function ($scope, $state, $sanitize, Authentication, UserService, Notifications,
}; };
function initView() { function initView() {
$scope.userID = Authentication.getUserDetails().ID;
SettingsService.publicSettings() SettingsService.publicSettings()
.then(function success(data) { .then(function success(data) {
$scope.AuthenticationMethod = data.AuthenticationMethod; $scope.AuthenticationMethod = data.AuthenticationMethod;

View File

@ -1,12 +1,12 @@
angular.module('volume', []) angular.module('volume', [])
.controller('VolumeController', ['$scope', '$state', '$stateParams', 'VolumeService', 'Notifications', .controller('VolumeController', ['$scope', '$state', '$transition$', 'VolumeService', 'Notifications',
function ($scope, $state, $stateParams, VolumeService, Notifications) { function ($scope, $state, $transition$, VolumeService, Notifications) {
$scope.removeVolume = function removeVolume() { $scope.removeVolume = function removeVolume() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
VolumeService.remove($scope.volume) VolumeService.remove($scope.volume)
.then(function success(data) { .then(function success(data) {
Notifications.success('Volume successfully removed', $stateParams.id); Notifications.success('Volume successfully removed', $transition$.params().id);
$state.go('volumes', {}); $state.go('volumes', {});
}) })
.catch(function error(err) { .catch(function error(err) {
@ -19,7 +19,7 @@ function ($scope, $state, $stateParams, VolumeService, Notifications) {
function initView() { function initView() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
VolumeService.volume($stateParams.id) VolumeService.volume($transition$.params().id)
.then(function success(data) { .then(function success(data) {
var volume = data; var volume = data;
$scope.volume = volume; $scope.volume = volume;

View File

@ -57,6 +57,13 @@
<span ng-show="sortType == 'Id' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Id' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th>
<a ui-sref="volumes" ng-click="order('StackName')">
Stack
<span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th> <th>
<a ui-sref="volumes" ng-click="order('Driver')"> <a ui-sref="volumes" ng-click="order('Driver')">
Driver Driver
@ -87,6 +94,7 @@
<a ui-sref="volume({id: volume.Id})" class="monospaced">{{ volume.Id|truncate:25 }}</a> <a ui-sref="volume({id: volume.Id})" class="monospaced">{{ volume.Id|truncate:25 }}</a>
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="volume.dangling">Unused</span></td> <span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="volume.dangling">Unused</span></td>
</td> </td>
<td>{{ volume.StackName ? volume.StackName : '-' }}</td>
<td>{{ volume.Driver }}</td> <td>{{ volume.Driver }}</td>
<td>{{ volume.Mountpoint | truncatelr }}</td> <td>{{ volume.Mountpoint | truncatelr }}</td>
<td ng-if="applicationState.application.authentication"> <td ng-if="applicationState.application.authentication">

37
app/config.js Normal file
View File

@ -0,0 +1,37 @@
angular.module('portainer')
.config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', 'AnalyticsProvider', '$uibTooltipProvider', '$compileProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider, $uibTooltipProvider, $compileProvider) {
'use strict';
var environment = '@@ENVIRONMENT';
if (environment === 'production') {
$compileProvider.debugInfoEnabled(false);
}
localStorageServiceProvider
.setPrefix('portainer');
jwtOptionsProvider.config({
tokenGetter: ['LocalStorage', function(LocalStorage) {
return LocalStorage.getJWT();
}],
unauthenticatedRedirector: ['$state', function($state) {
$state.go('auth', {error: 'Your session has expired'});
}]
});
$httpProvider.interceptors.push('jwtInterceptor');
AnalyticsProvider.setAccount('@@CONFIG_GA_ID');
AnalyticsProvider.startOffline(true);
toastr.options.timeOut = 3000;
$uibTooltipProvider.setTriggers({
'mouseenter': 'mouseleave',
'click': 'click',
'focus': 'blur',
'outsideClick': 'outsideClick'
});
$urlRouterProvider.otherwise('/auth');
configureRoutes($stateProvider);
}]);

14
app/constants.js Normal file
View File

@ -0,0 +1,14 @@
angular.module('portainer')
.constant('API_ENDPOINT_AUTH', 'api/auth')
.constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub')
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
.constant('API_ENDPOINT_STATUS', 'api/status')
.constant('API_ENDPOINT_USERS', 'api/users')
.constant('API_ENDPOINT_TEAMS', 'api/teams')
.constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships')
.constant('API_ENDPOINT_TEMPLATES', 'api/templates')
.constant('DEFAULT_TEMPLATES_URL', 'https://raw.githubusercontent.com/portainer/templates/master/templates.json')
.constant('PAGINATION_MAX_ITEMS', 10);

View File

@ -37,6 +37,13 @@
<portainer-tooltip message="Access control applied on a container created using a template is also applied on each volume associated to the container." position="bottom" style="margin-left: 2px;"></portainer-tooltip> <portainer-tooltip message="Access control applied on a container created using a template is also applied on each volume associated to the container." position="bottom" style="margin-left: 2px;"></portainer-tooltip>
</td> </td>
</tr> </tr>
<tr ng-if="$ctrl.resourceControl.Type === 6 && $ctrl.resourceType !== 'stack'">
<td colspan="2">
<i class="fa fa-info-circle" aria-hidden="true" style="margin-right: 2px;"></i>
Access control on this resource is inherited from the following stack: {{ $ctrl.resourceControl.ResourceId }}
<portainer-tooltip message="Access control applied on a stack is also applied on each resource in the stack." position="bottom" style="margin-left: 2px;"></portainer-tooltip>
</td>
</tr>
<!-- authorized-users --> <!-- authorized-users -->
<tr ng-if="$ctrl.resourceControl.UserAccesses.length > 0"> <tr ng-if="$ctrl.resourceControl.UserAccesses.length > 0">
<td>Authorized users</td> <td>Authorized users</td>
@ -54,7 +61,11 @@
</tr> </tr>
<!-- !authorized-teams --> <!-- !authorized-teams -->
<!-- edit-ownership --> <!-- edit-ownership -->
<tr ng-if="!($ctrl.resourceControl.Type === 1 && $ctrl.resourceType === 'volume') && !($ctrl.resourceControl.Type === 2 && $ctrl.resourceType === 'container') && !$ctrl.state.editOwnership && ($ctrl.isAdmin || $ctrl.state.canEditOwnership)"> <tr ng-if="!($ctrl.resourceControl.Type === 1 && $ctrl.resourceType === 'volume')
&& !($ctrl.resourceControl.Type === 2 && $ctrl.resourceType === 'container')
&& !($ctrl.resourceControl.Type === 6 && $ctrl.resourceType !== 'stack')
&& !$ctrl.state.editOwnership
&& ($ctrl.isAdmin || $ctrl.state.canEditOwnership)">
<td colspan="2"> <td colspan="2">
<a class="btn-outline-secondary" ng-click="$ctrl.state.editOwnership = true"><i class="fa fa-edit space-right" aria-hidden="true"></i>Change ownership</a> <a class="btn-outline-secondary" ng-click="$ctrl.state.editOwnership = true"><i class="fa fa-edit space-right" aria-hidden="true"></i>Change ownership</a>
</td> </td>

View File

@ -0,0 +1,8 @@
angular.module('portainer').component('porServiceList', {
templateUrl: 'app/directives/serviceList/porServiceList.html',
controller: 'porServiceListController',
bindings: {
'services': '<',
'nodes': '<'
}
});

View File

@ -0,0 +1,98 @@
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-list-alt" title="Associated services">
<div class="pull-right">
Items per page:
<select ng-model="$ctrl.state.pagination_count" ng-change="$ctrl.changePaginationCount()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-sm-12">
<div class="pull-right">
<input type="text" id="filter" ng-model="$ctrl.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>
<tr>
<th>
<a ng-click="$ctrl.order('Name')">
Name
<span ng-show="$ctrl.sortType === 'Name' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Name' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('Image')">
Image
<span ng-show="$ctrl.sortType === 'Image' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Image' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('Mode')">
Scheduling mode
<span ng-show="$ctrl.sortType === 'Mode' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Mode' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('Ports')">
Published Ports
<span ng-show="$ctrl.sortType === 'Ports' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Ports' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('UpdatedAt')">
Updated at
<span ng-show="$ctrl.sortType === 'UpdatedAt' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'UpdatedAt' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="service in $ctrl.services | filter:$ctrl.state.filter | orderBy:$ctrl.sortType:$ctrl.sortReverse | itemsPerPage:$ctrl.state.pagination_count" pagination-id="services_list">
<td><a ui-sref="service({id: service.Id})">{{ service.Name }}</a></td>
<td>{{ service.Image | hideshasum }}</td>
<td>
{{ service.Mode }}
<code data-toggle="tooltip" title="Replicas">{{ service.Tasks | runningtaskscount }}</code>
/
<code data-toggle="tooltip" title="Replicas">{{ service.Mode === 'replicated' ? service.Replicas : ($ctrl.nodes | availablenodecount) }}</code>
</td>
<td>
<a ng-if="service.Ports && service.Ports.length > 0" ng-repeat="p in service.Ports" class="image-tag" ng-href="http://{{$ctrl.state.publicURL}}:{{p.PublishedPort}}" target="_blank">
<i class="fa fa-external-link" aria-hidden="true"></i> {{ p.PublishedPort }}:{{ p.TargetPort }}
</a>
<span ng-if="!service.Ports || service.Ports.length === 0" >-</span>
</td>
<td>
{{ service.UpdatedAt|getisodate }}
</td>
</tr>
<tr ng-if="!$ctrl.services">
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="($ctrl.services | filter:$ctrl.state.filter | orderBy:$ctrl.sortType:$ctrl.sortReverse | itemsPerPage: $ctrl.state.pagination_count).length === 0">
<td colspan="5" class="text-center text-muted">No services available.</td>
</tr>
</tbody>
</table>
<div ng-if="$ctrl.services" class="pull-left pagination-controls">
<dir-pagination-controls pagination-id="services_list"></dir-pagination-controls >
</div>
</div>
</rd-widget-body>
<rd-widget>
</div>
</div>

View File

@ -0,0 +1,21 @@
angular.module('portainer')
.controller('porServiceListController', ['EndpointProvider', 'Pagination',
function (EndpointProvider, Pagination) {
var ctrl = this;
ctrl.state = {
pagination_count: Pagination.getPaginationCount('services_list'),
publicURL: EndpointProvider.endpointPublicURL()
};
ctrl.sortType = 'Name';
ctrl.sortReverse = false;
ctrl.order = function(sortType) {
ctrl.sortReverse = (ctrl.sortType === sortType) ? !ctrl.sortReverse : false;
ctrl.sortType = sortType;
};
ctrl.changePaginationCount = function() {
Pagination.setPaginationCount('services_list', ctrl.state.pagination_count);
};
}]);

View File

@ -8,6 +8,7 @@ angular.module('portainer')
step: ctrl.step, step: ctrl.step,
precision: ctrl.precision, precision: ctrl.precision,
showSelectionBar: true, showSelectionBar: true,
enforceStep: false,
translate: function(value, sliderId, label) { translate: function(value, sliderId, label) {
if (label === 'floor' || value === 0) { if (label === 'floor' || value === 0) {
return 'unlimited'; return 'unlimited';

View File

@ -0,0 +1,8 @@
angular.module('portainer').component('porTaskList', {
templateUrl: 'app/directives/taskList/porTaskList.html',
controller: 'porTaskListController',
bindings: {
'tasks': '<',
'nodes': '<'
}
});

View File

@ -0,0 +1,78 @@
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Associated tasks">
<div class="pull-right">
Items per page:
<select ng-model="$ctrl.state.pagination_count" ng-change="$ctrl.changePaginationCount()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-sm-12">
<div class="pull-right">
<input type="text" id="filter" ng-model="$ctrl.state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<table class="table">
<thead>
<tr>
<th>Id</th>
<th>
<a ng-click="$ctrl.order('Status.State')">
Status
<span ng-show="$ctrl.sortType === 'Status.State' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Status.State' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="service.Mode !== 'global'">
<a ng-click="$ctrl.order('Slot')">
Slot
<span ng-show="$ctrl.sortType === 'Slot' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Slot' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('NodeId')">
Node
<span ng-show="$ctrl.sortType === 'NodeId' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'NodeId' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('Updated')">
Last update
<span ng-show="$ctrl.sortType === 'Updated' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Updated' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="task in $ctrl.tasks | filter:$ctrl.state.filter | orderBy:$ctrl.sortType:$ctrl.sortReverse | itemsPerPage:$ctrl.state.pagination_count">
<td><a ui-sref="task({ id: task.Id })">{{ task.Id }}</a></td>
<td><span class="label label-{{ task.Status.State | taskstatusbadge }}">{{ task.Status.State }}</span></td>
<td>{{ task.Slot ? task.Slot : '-' }}</td>
<td>{{ task.NodeId | tasknodename: $ctrl.nodes }}</td>
<td>{{ task.Updated | getisodate }}</td>
</tr>
<tr ng-if="!$ctrl.tasks">
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="($ctrl.tasks | filter:$ctrl.state.filter | orderBy:$ctrl.sortType:$ctrl.sortReverse | itemsPerPage: $ctrl.state.pagination_count).length === 0">
<td colspan="5" class="text-center text-muted">No tasks available.</td>
</tr>
</tbody>
</table>
<div class="pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,19 @@
angular.module('portainer')
.controller('porTaskListController', ['Pagination',
function (Pagination) {
var ctrl = this;
ctrl.state = {
pagination_count: Pagination.getPaginationCount('tasks_list')
};
ctrl.sortType = 'Updated';
ctrl.sortReverse = true;
ctrl.order = function(sortType) {
ctrl.sortReverse = (ctrl.sortType === sortType) ? !ctrl.sortReverse : false;
ctrl.sortType = sortType;
};
ctrl.changePaginationCount = function() {
Pagination.setPaginationCount('tasks_list', ctrl.state.pagination_count);
};
}]);

1
app/filters/__module.js Normal file
View File

@ -0,0 +1 @@
angular.module('portainer.filters', []);

View File

@ -75,7 +75,7 @@ angular.module('portainer.filters', [])
return 'warning'; return 'warning';
} else if (includeString(status, ['created'])) { } else if (includeString(status, ['created'])) {
return 'info'; return 'info';
} else if (includeString(status, ['stopped', 'unhealthy', 'dead'])) { } else if (includeString(status, ['stopped', 'unhealthy', 'dead', 'exited'])) {
return 'danger'; return 'danger';
} }
return 'success'; return 'success';
@ -298,6 +298,32 @@ angular.module('portainer.filters', [])
} }
}; };
}) })
.filter('availablenodecount', function () {
'use strict';
return function (nodes) {
var availableNodes = 0;
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];
if (node.Availability === 'active' && node.Status === 'ready') {
availableNodes++;
}
}
return availableNodes;
};
})
.filter('runningtaskscount', function () {
'use strict';
return function (tasks) {
var runningTasks = 0;
for (var i = 0; i < tasks.length; i++) {
var task = tasks[i];
if (task.Status.State === 'running') {
runningTasks++;
}
}
return runningTasks;
};
})
.filter('tasknodename', function () { .filter('tasknodename', function () {
'use strict'; 'use strict';
return function (nodeId, nodes) { return function (nodeId, nodes) {

1
app/helpers/__module.js Normal file
View File

@ -0,0 +1 @@
angular.module('portainer.helpers', []);

View File

@ -1,7 +1,23 @@
angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHelperFactory() { angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHelperFactory() {
'use strict'; 'use strict';
return {
serviceToConfig: function(service) { var helper = {};
helper.associateTasksToService = function(service, tasks) {
service.Tasks = [];
var otherServicesTasks = [];
for (var i = 0; i < tasks.length; i++) {
var task = tasks[i];
if (task.ServiceId === service.Id) {
service.Tasks.push(task);
} else {
otherServicesTasks.push(task);
}
}
tasks = otherServicesTasks;
};
helper.serviceToConfig = function(service) {
return { return {
Name: service.Spec.Name, Name: service.Spec.Name,
Labels: service.Spec.Labels, Labels: service.Spec.Labels,
@ -11,8 +27,9 @@ angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHe
Networks: service.Spec.Networks, Networks: service.Spec.Networks,
EndpointSpec: service.Spec.EndpointSpec EndpointSpec: service.Spec.EndpointSpec
}; };
}, };
translateKeyValueToPlacementPreferences: function(keyValuePreferences) {
helper.translateKeyValueToPlacementPreferences = function(keyValuePreferences) {
if (keyValuePreferences) { if (keyValuePreferences) {
var preferences = []; var preferences = [];
keyValuePreferences.forEach(function(preference) { keyValuePreferences.forEach(function(preference) {
@ -31,8 +48,9 @@ angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHe
return preferences; return preferences;
} }
return []; return [];
}, };
translateKeyValueToPlacementConstraints: function(keyValueConstraints) {
helper.translateKeyValueToPlacementConstraints = function(keyValueConstraints) {
if (keyValueConstraints) { if (keyValueConstraints) {
var constraints = []; var constraints = [];
keyValueConstraints.forEach(function(constraint) { keyValueConstraints.forEach(function(constraint) {
@ -43,8 +61,9 @@ angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHe
return constraints; return constraints;
} }
return []; return [];
}, };
translateEnvironmentVariables: function(env) {
helper.translateEnvironmentVariables = function(env) {
if (env) { if (env) {
var variables = []; var variables = [];
env.forEach(function(variable) { env.forEach(function(variable) {
@ -62,8 +81,9 @@ angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHe
return variables; return variables;
} }
return []; return [];
}, };
translateEnvironmentVariablesToEnv: function(env) {
helper.translateEnvironmentVariablesToEnv = function(env) {
if (env) { if (env) {
var variables = []; var variables = [];
env.forEach(function(variable) { env.forEach(function(variable) {
@ -74,8 +94,9 @@ angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHe
return variables; return variables;
} }
return []; return [];
}, };
translatePreferencesToKeyValue: function(preferences) {
helper.translatePreferencesToKeyValue = function(preferences) {
if (preferences) { if (preferences) {
var keyValuePreferences = []; var keyValuePreferences = [];
preferences.forEach(function(preference) { preferences.forEach(function(preference) {
@ -89,8 +110,9 @@ angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHe
return keyValuePreferences; return keyValuePreferences;
} }
return []; return [];
}, };
translateConstraintsToKeyValue: function(constraints) {
helper.translateConstraintsToKeyValue = function(constraints) {
function getOperator(constraint) { function getOperator(constraint) {
var indexEquals = constraint.indexOf('=='); var indexEquals = constraint.indexOf('==');
if (indexEquals >= 0) { if (indexEquals >= 0) {
@ -117,7 +139,7 @@ angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHe
}); });
return keyValueConstraints; return keyValueConstraints;
} }
return [];
}
}; };
return helper;
}]); }]);

View File

@ -0,0 +1,21 @@
angular.module('portainer.helpers')
.factory('StackHelper', [function StackHelperFactory() {
'use strict';
var helper = {};
helper.getExternalStackNamesFromServices = function(services) {
var stackNames = [];
for (var i = 0; i < services.length; i++) {
var service = services[i];
if (!service.Labels || !service.Labels['com.docker.stack.namespace']) continue;
var stackName = service.Labels['com.docker.stack.namespace'];
stackNames.push(stackName);
}
return _.uniq(stackNames);
};
return helper;
}]);

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