1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-19 13:29:41 +02:00

feat(agent): add agent support (#1828)

This commit is contained in:
Anthony Lapenna 2018-05-06 09:15:57 +02:00 committed by GitHub
parent 77a85bd385
commit 2327d696e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
116 changed files with 1900 additions and 689 deletions

View file

@ -0,0 +1,20 @@
package bolt
import "github.com/portainer/portainer"
func (m *Migrator) updateEndpointsToVersion10() error {
legacyEndpoints, err := m.EndpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range legacyEndpoints {
endpoint.Type = portainer.DockerEnvironment
err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
return nil
}

View file

@ -104,6 +104,14 @@ func (m *Migrator) Migrate() error {
} }
} }
// https://github.com/portainer/portainer/issues/461
if m.CurrentDBVersion < 10 {
err := m.updateEndpointsToVersion10()
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

View file

@ -38,6 +38,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(), NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(),
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAnalytics).Bool(), NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAnalytics).Bool(),
TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(), TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(),
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(), TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(),
TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(), TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(),
TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(), TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(),

View file

@ -9,6 +9,7 @@ const (
defaultNoAuth = "false" defaultNoAuth = "false"
defaultNoAnalytics = "false" defaultNoAnalytics = "false"
defaultTLSVerify = "false" defaultTLSVerify = "false"
defaultTLSSkipVerify = "false"
defaultTLSCACertPath = "/certs/ca.pem" defaultTLSCACertPath = "/certs/ca.pem"
defaultTLSCertPath = "/certs/cert.pem" defaultTLSCertPath = "/certs/cert.pem"
defaultTLSKeyPath = "/certs/key.pem" defaultTLSKeyPath = "/certs/key.pem"

View file

@ -7,6 +7,7 @@ const (
defaultNoAuth = "false" defaultNoAuth = "false"
defaultNoAnalytics = "false" defaultNoAnalytics = "false"
defaultTLSVerify = "false" defaultTLSVerify = "false"
defaultTLSSkipVerify = "false"
defaultTLSCACertPath = "C:\\certs\\ca.pem" defaultTLSCACertPath = "C:\\certs\\ca.pem"
defaultTLSCertPath = "C:\\certs\\cert.pem" defaultTLSCertPath = "C:\\certs\\cert.pem"
defaultTLSKeyPath = "C:\\certs\\key.pem" defaultTLSKeyPath = "C:\\certs\\key.pem"

View file

@ -10,6 +10,7 @@ import (
"github.com/portainer/portainer/filesystem" "github.com/portainer/portainer/filesystem"
"github.com/portainer/portainer/git" "github.com/portainer/portainer/git"
"github.com/portainer/portainer/http" "github.com/portainer/portainer/http"
"github.com/portainer/portainer/http/client"
"github.com/portainer/portainer/jwt" "github.com/portainer/portainer/jwt"
"github.com/portainer/portainer/ldap" "github.com/portainer/portainer/ldap"
@ -61,8 +62,8 @@ func initStore(dataStorePath string) *bolt.Store {
return store return store
} }
func initStackManager(assetsPath string) portainer.StackManager { func initStackManager(assetsPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.StackManager, error) {
return exec.NewStackManager(assetsPath) return exec.NewStackManager(assetsPath, signatureService, fileService)
} }
func initJWTService(authenticationEnabled bool) portainer.JWTService { func initJWTService(authenticationEnabled bool) portainer.JWTService {
@ -76,6 +77,10 @@ func initJWTService(authenticationEnabled bool) portainer.JWTService {
return nil return nil
} }
func initDigitalSignatureService() portainer.DigitalSignatureService {
return &crypto.ECDSAService{}
}
func initCryptoService() portainer.CryptoService { func initCryptoService() portainer.CryptoService {
return &crypto.Service{} return &crypto.Service{}
} }
@ -173,6 +178,35 @@ func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService
return &endpoints[0] return &endpoints[0]
} }
func loadAndParseKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error {
private, public, err := fileService.LoadKeyPair()
if err != nil {
return err
}
return signatureService.ParseKeyPair(private, public)
}
func generateAndStoreKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error {
private, public, err := signatureService.GenerateKeyPair()
if err != nil {
return err
}
privateHeader, publicHeader := signatureService.PEMHeaders()
return fileService.StoreKeyPair(private, public, privateHeader, publicHeader)
}
func initKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error {
existingKeyPair, err := fileService.KeyPairFilesExist()
if err != nil {
log.Fatal(err)
}
if existingKeyPair {
return loadAndParseKeyPair(fileService, signatureService)
}
return generateAndStoreKeyPair(fileService, signatureService)
}
func main() { func main() {
flags := initCLI() flags := initCLI()
@ -181,19 +215,29 @@ 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()
digitalSignatureService := initDigitalSignatureService()
ldapService := initLDAPService() ldapService := initLDAPService()
gitService := initGitService() gitService := initGitService()
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval) authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
err := initSettings(store.SettingsService, flags) err := initKeyPair(fileService, digitalSignatureService)
if err != nil {
log.Fatal(err)
}
stackManager, err := initStackManager(*flags.Assets, digitalSignatureService, fileService)
if err != nil {
log.Fatal(err)
}
err = initSettings(store.SettingsService, flags)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -214,9 +258,10 @@ func main() {
endpoint := &portainer.Endpoint{ endpoint := &portainer.Endpoint{
Name: "primary", Name: "primary",
URL: *flags.Endpoint, URL: *flags.Endpoint,
Type: portainer.DockerEnvironment,
TLSConfig: portainer.TLSConfiguration{ TLSConfig: portainer.TLSConfiguration{
TLS: *flags.TLSVerify, TLS: *flags.TLSVerify,
TLSSkipVerify: false, TLSSkipVerify: *flags.TLSSkipVerify,
TLSCACertPath: *flags.TLSCacert, TLSCACertPath: *flags.TLSCacert,
TLSCertPath: *flags.TLSCert, TLSCertPath: *flags.TLSCert,
TLSKeyPath: *flags.TLSKey, TLSKeyPath: *flags.TLSKey,
@ -225,6 +270,16 @@ func main() {
AuthorizedTeams: []portainer.TeamID{}, AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{}, Extensions: []portainer.EndpointExtension{},
} }
agentOnDockerEnvironment, err := client.ExecutePingOperationFromEndpoint(endpoint)
if err != nil {
log.Fatal(err)
}
if agentOnDockerEnvironment {
endpoint.Type = portainer.AgentOnDockerEnvironment
}
err = store.EndpointService.CreateEndpoint(endpoint) err = store.EndpointService.CreateEndpoint(endpoint)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@ -292,6 +347,7 @@ func main() {
FileService: fileService, FileService: fileService,
LDAPService: ldapService, LDAPService: ldapService,
GitService: gitService, GitService: gitService,
SignatureService: digitalSignatureService,
SSL: *flags.SSL, SSL: *flags.SSL,
SSLCert: *flags.SSLCert, SSLCert: *flags.SSLCert,
SSLKey: *flags.SSLKey, SSLKey: *flags.SSLKey,

125
api/crypto/ecdsa.go Normal file
View file

@ -0,0 +1,125 @@
package crypto
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/md5"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"math/big"
)
const (
// PrivateKeyPemHeader represents the header that is appended to the PEM file when
// storing the private key.
PrivateKeyPemHeader = "EC PRIVATE KEY"
// PublicKeyPemHeader represents the header that is appended to the PEM file when
// storing the public key.
PublicKeyPemHeader = "ECDSA PUBLIC KEY"
)
// ECDSAService is a service used to create digital signatures when communicating with
// an agent based environment. It will automatically generates a key pair using ECDSA or
// can also reuse an existing ECDSA key pair.
type ECDSAService struct {
privateKey *ecdsa.PrivateKey
publicKey *ecdsa.PublicKey
encodedPubKey string
}
// EncodedPublicKey returns the encoded version of the public that can be used
// to be shared with other services. It's the hexadecimal encoding of the public key
// content.
func (service *ECDSAService) EncodedPublicKey() string {
return service.encodedPubKey
}
// PEMHeaders returns the ECDSA PEM headers.
func (service *ECDSAService) PEMHeaders() (string, string) {
return PrivateKeyPemHeader, PublicKeyPemHeader
}
// ParseKeyPair parses existing private/public key pair content and associate
// the parsed keys to the service.
func (service *ECDSAService) ParseKeyPair(private, public []byte) error {
privateKey, err := x509.ParseECPrivateKey(private)
if err != nil {
return err
}
service.privateKey = privateKey
encodedKey := hex.EncodeToString(public)
service.encodedPubKey = encodedKey
publicKey, err := x509.ParsePKIXPublicKey(public)
if err != nil {
return err
}
service.publicKey = publicKey.(*ecdsa.PublicKey)
return nil
}
// GenerateKeyPair will create a new key pair using ECDSA.
func (service *ECDSAService) GenerateKeyPair() ([]byte, []byte, error) {
pubkeyCurve := elliptic.P256()
privatekey, err := ecdsa.GenerateKey(pubkeyCurve, rand.Reader)
if err != nil {
return nil, nil, err
}
service.privateKey = privatekey
service.publicKey = &privatekey.PublicKey
private, err := x509.MarshalECPrivateKey(service.privateKey)
if err != nil {
return nil, nil, err
}
public, err := x509.MarshalPKIXPublicKey(service.publicKey)
if err != nil {
return nil, nil, err
}
encodedKey := hex.EncodeToString(public)
service.encodedPubKey = encodedKey
return private, public, nil
}
// Sign creates a signature from a message.
// It automatically hash the message using MD5 and creates a signature from
// that hash.
// It then encodes the generated signature in base64.
func (service *ECDSAService) Sign(message string) (string, error) {
digest := md5.New()
digest.Write([]byte(message))
hash := digest.Sum(nil)
r := big.NewInt(0)
s := big.NewInt(0)
r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash)
if err != nil {
return "", err
}
keyBytes := service.privateKey.Params().BitSize / 8
rBytes := r.Bytes()
rBytesPadded := make([]byte, keyBytes)
copy(rBytesPadded[keyBytes-len(rBytes):], rBytes)
sBytes := s.Bytes()
sBytesPadded := make([]byte, keyBytes)
copy(sBytesPadded[keyBytes-len(sBytes):], sBytes)
signature := append(rBytesPadded, sBytesPadded...)
return base64.RawStdEncoding.EncodeToString(signature), nil
}

View file

@ -33,7 +33,7 @@ func CreateTLSConfig(caCert, cert, key []byte, skipClientVerification, skipServe
func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, error) { func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, error) {
TLSConfig := &tls.Config{} TLSConfig := &tls.Config{}
if config.TLSCertPath != "" && config.TLSKeyPath != "" { if config.TLS && config.TLSCertPath != "" && config.TLSKeyPath != "" {
cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath) cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath)
if err != nil { if err != nil {
return nil, err return nil, err
@ -42,7 +42,7 @@ func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, er
TLSConfig.Certificates = []tls.Certificate{cert} TLSConfig.Certificates = []tls.Certificate{cert}
} }
if !config.TLSSkipVerify { if config.TLS && !config.TLSSkipVerify {
caCert, err := ioutil.ReadFile(config.TLSCACertPath) caCert, err := ioutil.ReadFile(config.TLSCACertPath)
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -12,14 +12,34 @@ import (
// StackManager represents a service for managing stacks. // StackManager represents a service for managing stacks.
type StackManager struct { type StackManager struct {
binaryPath string binaryPath string
signatureService portainer.DigitalSignatureService
fileService portainer.FileService
}
type dockerCLIConfiguration struct {
HTTPHeaders struct {
ManagerOperationHeader string `json:"X-PortainerAgent-ManagerOperation"`
SignatureHeader string `json:"X-PortainerAgent-Signature"`
PublicKey string `json:"X-PortainerAgent-PublicKey"`
} `json:"HttpHeaders"`
} }
// NewStackManager initializes a new StackManager service. // NewStackManager initializes a new StackManager service.
func NewStackManager(binaryPath string) *StackManager { // It also updates the configuration of the Docker CLI binary.
return &StackManager{ func NewStackManager(binaryPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*StackManager, error) {
binaryPath: binaryPath, manager := &StackManager{
binaryPath: binaryPath,
signatureService: signatureService,
fileService: fileService,
} }
err := manager.updateDockerCLIConfiguration(binaryPath)
if err != nil {
return nil, err
}
return manager, nil
} }
// Login executes the docker login command against a list of registries (including DockerHub). // Login executes the docker login command against a list of registries (including DockerHub).
@ -99,9 +119,12 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint
} }
args := make([]string, 0) args := make([]string, 0)
args = append(args, "--config", binaryPath)
args = append(args, "-H", endpoint.URL) args = append(args, "-H", endpoint.URL)
if endpoint.TLSConfig.TLS { if !endpoint.TLSConfig.TLS && endpoint.TLSConfig.TLSSkipVerify {
args = append(args, "--tls")
} else if endpoint.TLSConfig.TLS {
args = append(args, "--tls") args = append(args, "--tls")
if !endpoint.TLSConfig.TLSSkipVerify { if !endpoint.TLSConfig.TLSSkipVerify {
@ -115,3 +138,22 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint
return command, args return command, args
} }
func (manager *StackManager) updateDockerCLIConfiguration(binaryPath string) error {
config := dockerCLIConfiguration{}
config.HTTPHeaders.ManagerOperationHeader = "1"
signature, err := manager.signatureService.Sign(portainer.PortainerAgentSignatureMessage)
if err != nil {
return err
}
config.HTTPHeaders.SignatureHeader = signature
config.HTTPHeaders.PublicKey = manager.signatureService.EncodedPublicKey()
err = manager.fileService.WriteJSONToFile(path.Join(binaryPath, "config.json"), config)
if err != nil {
return err
}
return nil
}

View file

@ -2,6 +2,8 @@ package filesystem
import ( import (
"bytes" "bytes"
"encoding/json"
"encoding/pem"
"io/ioutil" "io/ioutil"
"github.com/portainer/portainer" "github.com/portainer/portainer"
@ -26,6 +28,10 @@ const (
ComposeStorePath = "compose" ComposeStorePath = "compose"
// ComposeFileDefaultName represents the default name of a compose file. // ComposeFileDefaultName represents the default name of a compose file.
ComposeFileDefaultName = "docker-compose.yml" ComposeFileDefaultName = "docker-compose.yml"
// PrivateKeyFile represents the name on disk of the file containing the private key.
PrivateKeyFile = "portainer.key"
// PublicKeyFile represents the name on disk of the file containing the public key.
PublicKeyFile = "portainer.pub"
) )
// Service represents a service for managing files and directories. // Service represents a service for managing files and directories.
@ -198,6 +204,69 @@ func (service *Service) GetFileContent(filePath string) (string, error) {
return string(content), nil return string(content), nil
} }
// WriteJSONToFile writes JSON to the specified file.
func (service *Service) WriteJSONToFile(path string, content interface{}) error {
jsonContent, err := json.Marshal(content)
if err != nil {
return err
}
return ioutil.WriteFile(path, jsonContent, 0644)
}
// KeyPairFilesExist checks for the existence of the key files.
func (service *Service) KeyPairFilesExist() (bool, error) {
privateKeyPath := path.Join(service.dataStorePath, PrivateKeyFile)
exists, err := fileExists(privateKeyPath)
if err != nil {
return false, err
}
if !exists {
return false, nil
}
publicKeyPath := path.Join(service.dataStorePath, PublicKeyFile)
exists, err = fileExists(publicKeyPath)
if err != nil {
return false, err
}
if !exists {
return false, nil
}
return true, nil
}
// StoreKeyPair store the specified keys content as PEM files on disk.
func (service *Service) StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error {
err := service.createPEMFileInStore(private, privatePEMHeader, PrivateKeyFile)
if err != nil {
return err
}
err = service.createPEMFileInStore(public, publicPEMHeader, PublicKeyFile)
if err != nil {
return err
}
return nil
}
// LoadKeyPair retrieve the content of both key files on disk.
func (service *Service) LoadKeyPair() ([]byte, []byte, error) {
privateKey, err := service.getContentFromPEMFile(PrivateKeyFile)
if err != nil {
return nil, nil, err
}
publicKey, err := service.getContentFromPEMFile(PublicKeyFile)
if err != nil {
return nil, nil, err
}
return privateKey, publicKey, nil
}
// createDirectoryInStore creates a new directory in the file store // createDirectoryInStore creates a new directory in the file store
func (service *Service) createDirectoryInStore(name string) error { func (service *Service) createDirectoryInStore(name string) error {
path := path.Join(service.fileStorePath, name) path := path.Join(service.fileStorePath, name)
@ -221,3 +290,43 @@ func (service *Service) createFileInStore(filePath string, r io.Reader) error {
return nil return nil
} }
func (service *Service) createPEMFileInStore(content []byte, fileType, filePath string) error {
path := path.Join(service.fileStorePath, filePath)
block := &pem.Block{Type: fileType, Bytes: content}
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer out.Close()
err = pem.Encode(out, block)
if err != nil {
return err
}
return nil
}
func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) {
path := path.Join(service.fileStorePath, filePath)
fileContent, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(fileContent)
return block.Bytes, nil
}
func fileExists(filePath string) (bool, error) {
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
}

77
api/http/client/client.go Normal file
View file

@ -0,0 +1,77 @@
package client
import (
"crypto/tls"
"net/http"
"strings"
"time"
"github.com/portainer/portainer"
"github.com/portainer/portainer/crypto"
)
// ExecutePingOperationFromEndpoint will send a SystemPing operation HTTP request to a Docker environment
// using the specified endpoint configuration. It is used exclusively when
// specifying an endpoint from the CLI via the -H flag.
func ExecutePingOperationFromEndpoint(endpoint *portainer.Endpoint) (bool, error) {
if strings.HasPrefix(endpoint.URL, "unix://") {
return false, nil
}
transport := &http.Transport{}
scheme := "http"
if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify {
tlsConfig, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
if err != nil {
return false, err
}
scheme = "https"
transport.TLSClientConfig = tlsConfig
}
client := &http.Client{
Timeout: time.Second * 3,
Transport: transport,
}
target := strings.Replace(endpoint.URL, "tcp://", scheme+"://", 1)
return pingOperation(client, target)
}
// ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment
// using the specified host and optional TLS configuration.
func ExecutePingOperation(host string, tlsConfig *tls.Config) (bool, error) {
transport := &http.Transport{}
scheme := "http"
if tlsConfig != nil {
transport.TLSClientConfig = tlsConfig
scheme = "https"
}
client := &http.Client{
Timeout: time.Second * 3,
Transport: transport,
}
target := strings.Replace(host, "tcp://", scheme+"://", 1)
return pingOperation(client, target)
}
func pingOperation(client *http.Client, target string) (bool, error) {
pingOperationURL := target + "/_ping"
response, err := client.Get(pingOperationURL)
if err != nil {
return false, err
}
agentOnDockerEnvironment := false
if response.Header.Get(portainer.PortainerAgentHeader) != "" {
agentOnDockerEnvironment = true
}
return agentOnDockerEnvironment, nil
}

View file

@ -2,12 +2,11 @@ package handler
import ( import (
"bytes" "bytes"
"crypto/tls"
"strings" "strings"
"time"
"github.com/portainer/portainer" "github.com/portainer/portainer"
"github.com/portainer/portainer/crypto" "github.com/portainer/portainer/crypto"
"github.com/portainer/portainer/http/client"
httperror "github.com/portainer/portainer/http/error" httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/proxy" "github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security" "github.com/portainer/portainer/http/security"
@ -63,10 +62,6 @@ func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManag
} }
type ( type (
postEndpointsResponse struct {
ID int `json:"Id"`
}
putEndpointAccessRequest struct { putEndpointAccessRequest struct {
AuthorizedUsers []int `valid:"-"` AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"` AuthorizedTeams []int `valid:"-"`
@ -125,44 +120,26 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt
encodeJSON(w, filteredEndpoints, handler.Logger) encodeJSON(w, filteredEndpoints, handler.Logger)
} }
func sendPingRequest(host string, tlsConfig *tls.Config) error {
transport := &http.Transport{}
scheme := "http"
if tlsConfig != nil {
transport.TLSClientConfig = tlsConfig
scheme = "https"
}
client := &http.Client{
Timeout: time.Second * 3,
Transport: transport,
}
pingOperationURL := strings.Replace(host, "tcp://", scheme+"://", 1) + "/_ping"
_, err := client.Get(pingOperationURL)
if err != nil {
return err
}
return nil
}
func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) {
tlsConfig, err := crypto.CreateTLSConfig(payload.caCert, payload.cert, payload.key, payload.skipTLSClientVerification, payload.skipTLSServerVerification) tlsConfig, err := crypto.CreateTLSConfig(payload.caCert, payload.cert, payload.key, payload.skipTLSClientVerification, payload.skipTLSServerVerification)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = sendPingRequest(payload.url, tlsConfig) agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, tlsConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} }
endpointType := portainer.DockerEnvironment
if agentOnDockerEnvironment {
endpointType = portainer.AgentOnDockerEnvironment
}
endpoint := &portainer.Endpoint{ endpoint := &portainer.Endpoint{
Name: payload.name, Name: payload.name,
URL: payload.url, URL: payload.url,
Type: endpointType,
GroupID: portainer.EndpointGroupID(payload.groupID), GroupID: portainer.EndpointGroupID(payload.groupID),
PublicURL: payload.publicURL, PublicURL: payload.publicURL,
TLSConfig: portainer.TLSConfiguration{ TLSConfig: portainer.TLSConfiguration{
@ -224,17 +201,22 @@ func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPa
} }
func (handler *EndpointHandler) createUnsecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { func (handler *EndpointHandler) createUnsecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) {
endpointType := portainer.DockerEnvironment
if !strings.HasPrefix(payload.url, "unix://") { if !strings.HasPrefix(payload.url, "unix://") {
err := sendPingRequest(payload.url, nil) agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if agentOnDockerEnvironment {
endpointType = portainer.AgentOnDockerEnvironment
}
} }
endpoint := &portainer.Endpoint{ endpoint := &portainer.Endpoint{
Name: payload.name, Name: payload.name,
URL: payload.url, URL: payload.url,
Type: endpointType,
GroupID: portainer.EndpointGroupID(payload.groupID), GroupID: portainer.EndpointGroupID(payload.groupID),
PublicURL: payload.publicURL, PublicURL: payload.publicURL,
TLSConfig: portainer.TLSConfiguration{ TLSConfig: portainer.TLSConfiguration{
@ -331,7 +313,7 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht
return return
} }
encodeJSON(w, &postEndpointsResponse{ID: int(endpoint.ID)}, handler.Logger) encodeJSON(w, &endpoint, handler.Logger)
} }
// handleGetEndpoint handles GET requests on /endpoints/:id // handleGetEndpoint handles GET requests on /endpoints/:id

View file

@ -1,11 +1,11 @@
package handler package handler
import ( import (
"bufio"
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net" "net"
"net/http" "net/http"
@ -16,119 +16,135 @@ import (
"time" "time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/koding/websocketproxy"
"github.com/portainer/portainer" "github.com/portainer/portainer"
"github.com/portainer/portainer/crypto" "github.com/portainer/portainer/crypto"
"golang.org/x/net/websocket" httperror "github.com/portainer/portainer/http/error"
) )
// WebSocketHandler represents an HTTP API handler for proxying requests to a web socket. type (
type WebSocketHandler struct { // WebSocketHandler represents an HTTP API handler for proxying requests to a web socket.
*mux.Router WebSocketHandler struct {
Logger *log.Logger *mux.Router
EndpointService portainer.EndpointService Logger *log.Logger
} EndpointService portainer.EndpointService
SignatureService portainer.DigitalSignatureService
connectionUpgrader websocket.Upgrader
}
webSocketExecRequestParams struct {
execID string
nodeName string
endpoint *portainer.Endpoint
}
execStartOperationPayload struct {
Tty bool
Detach bool
}
)
// NewWebSocketHandler returns a new instance of WebSocketHandler. // NewWebSocketHandler returns a new instance of WebSocketHandler.
func NewWebSocketHandler() *WebSocketHandler { func NewWebSocketHandler() *WebSocketHandler {
h := &WebSocketHandler{ h := &WebSocketHandler{
Router: mux.NewRouter(), Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags), Logger: log.New(os.Stderr, "", log.LstdFlags),
connectionUpgrader: websocket.Upgrader{},
} }
h.Handle("/websocket/exec", websocket.Handler(h.webSocketDockerExec)) h.HandleFunc("/websocket/exec", h.handleWebsocketExec).Methods(http.MethodGet)
return h return h
} }
func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) { // handleWebsocketExec handles GET requests on /websocket/exec?id=<execID>&endpointId=<endpointID>&nodeName=<nodeName>
qry := ws.Request().URL.Query() // If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint.
execID := qry.Get("id") // If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and
edpID := qry.Get("endpointId") // an ExecStart operation HTTP request will be created and hijacked.
func (handler *WebSocketHandler) handleWebsocketExec(w http.ResponseWriter, r *http.Request) {
parsedID, err := strconv.Atoi(edpID) paramExecID := r.FormValue("id")
if err != nil { paramEndpointID := r.FormValue("endpointId")
log.Printf("Unable to parse endpoint ID: %s", err) if paramExecID == "" || paramEndpointID == "" {
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
return return
} }
endpointID := portainer.EndpointID(parsedID) endpointID, err := strconv.Atoi(paramEndpointID)
endpoint, err := handler.EndpointService.Endpoint(endpointID)
if err != nil { if err != nil {
log.Printf("Unable to retrieve endpoint: %s", err) httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return return
} }
endpointURL, err := url.Parse(endpoint.URL) endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err != nil { if err != nil {
log.Printf("Unable to parse endpoint URL: %s", err) httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return return
} }
var host string params := &webSocketExecRequestParams{
if endpointURL.Scheme == "tcp" { endpoint: endpoint,
host = endpointURL.Host execID: paramExecID,
} else if endpointURL.Scheme == "unix" { nodeName: r.FormValue("nodeName"),
host = endpointURL.Path
} }
// TODO: Should not be managed here err = handler.handleRequest(w, r, params)
var tlsConfig *tls.Config if err != nil {
if endpoint.TLSConfig.TLS { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
tlsConfig, err = crypto.CreateTLSConfiguration(&endpoint.TLSConfig) return
if err != nil { }
log.Fatalf("Unable to create TLS configuration: %s", err) }
return
func (handler *WebSocketHandler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error {
if params.nodeName != "" {
return handler.proxyWebsocketRequest(w, r, params)
}
websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil)
if err != nil {
return err
}
defer websocketConn.Close()
return hijackExecStartOperation(websocketConn, params.endpoint, params.execID)
}
func (handler *WebSocketHandler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error {
agentURL, err := url.Parse(params.endpoint.URL)
if err != nil {
return err
}
agentURL.Scheme = "ws"
proxy := websocketproxy.NewProxy(agentURL)
if params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify {
agentURL.Scheme = "wss"
proxy.Dialer = &websocket.Dialer{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: params.endpoint.TLSConfig.TLSSkipVerify,
},
} }
} }
if err := hijack(host, endpointURL.Scheme, "POST", "/exec/"+execID+"/start", tlsConfig, true, ws, ws, ws, nil, nil); err != nil { signature, err := handler.SignatureService.Sign(portainer.PortainerAgentSignatureMessage)
log.Fatalf("error during hijack: %s", err) if err != nil {
return return err
} }
proxy.Director = func(incoming *http.Request, out http.Header) {
out.Set(portainer.PortainerAgentSignatureHeader, signature)
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
}
r.Header.Del("Origin")
proxy.ServeHTTP(w, r)
return nil
} }
type execConfig struct { func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, execID string) error {
Tty bool dial, err := createDial(endpoint)
Detach bool
}
// hijack allows to upgrade an HTTP connection to a TCP connection
// It redirects IO streams for stdin, stdout and stderr to a websocket
func hijack(addr, scheme, method, path string, tlsConfig *tls.Config, setRawTerminal bool, in io.ReadCloser, stdout, stderr io.Writer, started chan io.Closer, data interface{}) error {
execConfig := &execConfig{
Tty: true,
Detach: false,
}
buf, err := json.Marshal(execConfig)
if err != nil { if err != nil {
return fmt.Errorf("error marshaling exec config: %s", err) return err
}
rdr := bytes.NewReader(buf)
req, err := http.NewRequest(method, path, rdr)
if err != nil {
return fmt.Errorf("error during hijack request: %s", err)
}
req.Header.Set("User-Agent", "Docker-Client")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "tcp")
req.Host = addr
var (
dial net.Conn
dialErr error
)
if tlsConfig == nil {
dial, dialErr = net.Dial(scheme, addr)
} else {
dial, dialErr = tls.Dial(scheme, addr, tlsConfig)
}
if dialErr != nil {
return dialErr
} }
// When we set up a TCP connection for hijack, there could be long periods // When we set up a TCP connection for hijack, there could be long periods
@ -140,57 +156,128 @@ func hijack(addr, scheme, method, path string, tlsConfig *tls.Config, setRawTerm
tcpConn.SetKeepAlive(true) tcpConn.SetKeepAlive(true)
tcpConn.SetKeepAlivePeriod(30 * time.Second) tcpConn.SetKeepAlivePeriod(30 * time.Second)
} }
httpConn := httputil.NewClientConn(dial, nil)
defer httpConn.Close()
execStartRequest, err := createExecStartRequest(execID)
if err != nil { if err != nil {
return err return err
} }
clientconn := httputil.NewClientConn(dial, nil)
defer clientconn.Close()
// Server hijacks the connection, error 'connection closed' expected err = hijackRequest(websocketConn, httpConn, execStartRequest)
clientconn.Do(req) if err != nil {
return err
rwc, br := clientconn.Hijack()
defer rwc.Close()
if started != nil {
started <- rwc
} }
var receiveStdout chan error
if stdout != nil || stderr != nil {
go func() (err error) {
if setRawTerminal && stdout != nil {
_, err = io.Copy(stdout, br)
}
return err
}()
}
go func() error {
if in != nil {
io.Copy(rwc, in)
}
if conn, ok := rwc.(interface {
CloseWrite() error
}); ok {
if err := conn.CloseWrite(); err != nil {
}
}
return nil
}()
if stdout != nil || stderr != nil {
if err := <-receiveStdout; err != nil {
return err
}
}
go func() {
for {
fmt.Println(br)
}
}()
return nil return nil
} }
func createDial(endpoint *portainer.Endpoint) (net.Conn, error) {
url, err := url.Parse(endpoint.URL)
if err != nil {
return nil, err
}
var host string
if url.Scheme == "tcp" {
host = url.Host
} else if url.Scheme == "unix" {
host = url.Path
}
if endpoint.TLSConfig.TLS {
tlsConfig, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
if err != nil {
return nil, err
}
return tls.Dial(url.Scheme, host, tlsConfig)
}
return net.Dial(url.Scheme, host)
}
func createExecStartRequest(execID string) (*http.Request, error) {
execStartOperationPayload := &execStartOperationPayload{
Tty: true,
Detach: false,
}
encodedBody := bytes.NewBuffer(nil)
err := json.NewEncoder(encodedBody).Encode(execStartOperationPayload)
if err != nil {
return nil, err
}
request, err := http.NewRequest("POST", "/exec/"+execID+"/start", encodedBody)
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Connection", "Upgrade")
request.Header.Set("Upgrade", "tcp")
return request, nil
}
func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn, request *http.Request) error {
// Server hijacks the connection, error 'connection closed' expected
resp, err := httpConn.Do(request)
if err != httputil.ErrPersistEOF {
if err != nil {
return err
}
if resp.StatusCode != http.StatusSwitchingProtocols {
resp.Body.Close()
return fmt.Errorf("unable to upgrade to tcp, received %d", resp.StatusCode)
}
}
tcpConn, brw := httpConn.Hijack()
defer tcpConn.Close()
errorChan := make(chan error, 1)
go streamFromTCPConnToWebsocketConn(websocketConn, brw, errorChan)
go streamFromWebsocketConnToTCPConn(websocketConn, tcpConn, errorChan)
err = <-errorChan
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
return err
}
return nil
}
func streamFromWebsocketConnToTCPConn(websocketConn *websocket.Conn, tcpConn net.Conn, errorChan chan error) {
for {
_, in, err := websocketConn.ReadMessage()
if err != nil {
errorChan <- err
break
}
_, err = tcpConn.Write(in)
if err != nil {
errorChan <- err
break
}
}
}
func streamFromTCPConnToWebsocketConn(websocketConn *websocket.Conn, br *bufio.Reader, errorChan chan error) {
for {
out := make([]byte, 1024)
_, err := br.Read(out)
if err != nil {
errorChan <- err
break
}
err = websocketConn.WriteMessage(websocket.TextMessage, out)
if err != nil {
errorChan <- err
break
}
}
}

View file

@ -98,9 +98,12 @@ func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.Team
} }
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} { func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
metadata := make(map[string]interface{}) if object["Portainer"] == nil {
metadata["ResourceControl"] = resourceControl object["Portainer"] = make(map[string]interface{})
object["Portainer"] = metadata }
portainerMetadata := object["Portainer"].(map[string]interface{})
portainerMetadata["ResourceControl"] = resourceControl
return object return object
} }

View file

@ -14,7 +14,7 @@ const (
// configListOperation extracts the response as a JSON object, loop through the configs array // configListOperation extracts the response as a JSON object, loop through the configs array
// decorate and/or filter the configs based on resource controls before rewriting the response // decorate and/or filter the configs based on resource controls before rewriting the response
func configListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { func configListOperation(response *http.Response, executor *operationExecutor) error {
var err error var err error
// ConfigList response is a JSON array // ConfigList response is a JSON array
@ -39,7 +39,7 @@ func configListOperation(request *http.Request, response *http.Response, executo
// configInspectOperation extracts the response as a JSON object, verify that the user // configInspectOperation extracts the response as a JSON object, verify that the user
// has access to the config based on resource control (check are done based on the configID and optional Swarm service ID) // has access to the config based on resource control (check are done based on the configID and optional Swarm service ID)
// and either rewrite an access denied response or a decorated config. // and either rewrite an access denied response or a decorated config.
func configInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { func configInspectOperation(response *http.Response, executor *operationExecutor) error {
// ConfigInspect response is a JSON object // ConfigInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect // https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect
responseObject, err := getResponseAsJSONOBject(response) responseObject, err := getResponseAsJSONOBject(response)

View file

@ -16,7 +16,7 @@ const (
// 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
// decorate and/or filter the containers based on resource controls before rewriting the response // decorate and/or filter the containers based on resource controls before rewriting the response
func containerListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { func containerListOperation(response *http.Response, executor *operationExecutor) error {
var err error var err error
// ContainerList response is a JSON array // ContainerList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerList // https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
@ -47,7 +47,7 @@ func containerListOperation(request *http.Request, response *http.Response, exec
// containerInspectOperation extracts the response as a JSON object, verify that the user // containerInspectOperation extracts the response as a JSON object, verify that the user
// has access to the container based on resource control (check are done based on the containerID and optional Swarm service ID) // has access to the container based on resource control (check are done based on the containerID and optional Swarm service ID)
// and either rewrite an access denied response or a decorated container. // and either rewrite an access denied response or a decorated container.
func containerInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { func containerInspectOperation(response *http.Response, executor *operationExecutor) error {
// ContainerInspect response is a JSON object // ContainerInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect // https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
responseObject, err := getResponseAsJSONOBject(response) responseObject, err := getResponseAsJSONOBject(response)

View file

@ -17,6 +17,7 @@ type proxyFactory struct {
SettingsService portainer.SettingsService SettingsService portainer.SettingsService
RegistryService portainer.RegistryService RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService DockerHubService portainer.DockerHubService
SignatureService portainer.DigitalSignatureService
} }
func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler { func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler {
@ -24,10 +25,11 @@ func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler {
return newSingleHostReverseProxyWithHostHeader(u) return newSingleHostReverseProxyWithHostHeader(u)
} }
func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portainer.TLSConfiguration, enableSignature bool) (http.Handler, error) {
u.Scheme = "https" u.Scheme = "https"
proxy := factory.createDockerReverseProxy(u)
config, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig) proxy := factory.createDockerReverseProxy(u, enableSignature)
config, err := crypto.CreateTLSConfiguration(tlsConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -36,14 +38,15 @@ func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, endpoint *portainer
return proxy, nil return proxy, nil
} }
func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL) http.Handler { func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL, enableSignature bool) http.Handler {
u.Scheme = "http" u.Scheme = "http"
return factory.createDockerReverseProxy(u) return factory.createDockerReverseProxy(u, enableSignature)
} }
func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler { func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler {
proxy := &socketProxy{} proxy := &socketProxy{}
transport := &proxyTransport{ transport := &proxyTransport{
enableSignature: false,
ResourceControlService: factory.ResourceControlService, ResourceControlService: factory.ResourceControlService,
TeamMembershipService: factory.TeamMembershipService, TeamMembershipService: factory.TeamMembershipService,
SettingsService: factory.SettingsService, SettingsService: factory.SettingsService,
@ -55,9 +58,10 @@ func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler {
return proxy return proxy
} }
func (factory *proxyFactory) createDockerReverseProxy(u *url.URL) *httputil.ReverseProxy { func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, enableSignature bool) *httputil.ReverseProxy {
proxy := newSingleHostReverseProxyWithHostHeader(u) proxy := newSingleHostReverseProxyWithHostHeader(u)
transport := &proxyTransport{ transport := &proxyTransport{
enableSignature: enableSignature,
ResourceControlService: factory.ResourceControlService, ResourceControlService: factory.ResourceControlService,
TeamMembershipService: factory.TeamMembershipService, TeamMembershipService: factory.TeamMembershipService,
SettingsService: factory.SettingsService, SettingsService: factory.SettingsService,
@ -65,6 +69,11 @@ func (factory *proxyFactory) createDockerReverseProxy(u *url.URL) *httputil.Reve
DockerHubService: factory.DockerHubService, DockerHubService: factory.DockerHubService,
dockerTransport: &http.Transport{}, dockerTransport: &http.Transport{},
} }
if enableSignature {
transport.SignatureService = factory.SignatureService
}
proxy.Transport = transport proxy.Transport = transport
return proxy return proxy
} }

View file

@ -9,24 +9,37 @@ import (
"github.com/portainer/portainer" "github.com/portainer/portainer"
) )
// Manager represents a service used to manage Docker proxies. type (
type Manager struct { // Manager represents a service used to manage Docker proxies.
proxyFactory *proxyFactory Manager struct {
proxies cmap.ConcurrentMap proxyFactory *proxyFactory
extensionProxies cmap.ConcurrentMap proxies cmap.ConcurrentMap
} extensionProxies cmap.ConcurrentMap
}
// ManagerParams represents the required parameters to create a new Manager instance.
ManagerParams struct {
ResourceControlService portainer.ResourceControlService
TeamMembershipService portainer.TeamMembershipService
SettingsService portainer.SettingsService
RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService
SignatureService portainer.DigitalSignatureService
}
)
// NewManager initializes a new proxy Service // NewManager initializes a new proxy Service
func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService, settingsService portainer.SettingsService, registryService portainer.RegistryService, dockerHubService portainer.DockerHubService) *Manager { func NewManager(parameters *ManagerParams) *Manager {
return &Manager{ return &Manager{
proxies: cmap.New(), proxies: cmap.New(),
extensionProxies: cmap.New(), extensionProxies: cmap.New(),
proxyFactory: &proxyFactory{ proxyFactory: &proxyFactory{
ResourceControlService: resourceControlService, ResourceControlService: parameters.ResourceControlService,
TeamMembershipService: teamMembershipService, TeamMembershipService: parameters.TeamMembershipService,
SettingsService: settingsService, SettingsService: parameters.SettingsService,
RegistryService: registryService, RegistryService: parameters.RegistryService,
DockerHubService: dockerHubService, DockerHubService: parameters.DockerHubService,
SignatureService: parameters.SignatureService,
}, },
} }
} }
@ -41,14 +54,19 @@ func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (ht
return nil, err return nil, err
} }
enableSignature := false
if endpoint.Type == portainer.AgentOnDockerEnvironment {
enableSignature = true
}
if endpointURL.Scheme == "tcp" { if endpointURL.Scheme == "tcp" {
if endpoint.TLSConfig.TLS { if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify {
proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, endpoint) proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, enableSignature)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} else { } else {
proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL) proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL, enableSignature)
} }
} else { } else {
// Assume unix:// scheme // Assume unix:// scheme

View file

@ -15,7 +15,7 @@ const (
// 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
// decorate and/or filter the networks based on resource controls before rewriting the response // decorate and/or filter the networks based on resource controls before rewriting the response
func networkListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { func networkListOperation(response *http.Response, executor *operationExecutor) error {
var err error var err error
// NetworkList response is a JSON array // NetworkList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkList // https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
@ -39,7 +39,7 @@ func networkListOperation(request *http.Request, response *http.Response, execut
// networkInspectOperation extracts the response as a JSON object, verify that the user // networkInspectOperation extracts the response as a JSON object, verify that the user
// has access to the network based on resource control and either rewrite an access denied response // has access to the network based on resource control and either rewrite an access denied response
// or a decorated network. // or a decorated network.
func networkInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { func networkInspectOperation(response *http.Response, executor *operationExecutor) error {
// NetworkInspect response is a JSON object // NetworkInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect // https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
responseObject, err := getResponseAsJSONOBject(response) responseObject, err := getResponseAsJSONOBject(response)

View file

@ -14,7 +14,7 @@ const (
// secretListOperation extracts the response as a JSON object, loop through the secrets array // secretListOperation extracts the response as a JSON object, loop through the secrets array
// decorate and/or filter the secrets based on resource controls before rewriting the response // decorate and/or filter the secrets based on resource controls before rewriting the response
func secretListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { func secretListOperation(response *http.Response, executor *operationExecutor) error {
var err error var err error
// SecretList response is a JSON array // SecretList response is a JSON array
@ -39,7 +39,7 @@ func secretListOperation(request *http.Request, response *http.Response, executo
// secretInspectOperation extracts the response as a JSON object, verify that the user // secretInspectOperation extracts the response as a JSON object, verify that the user
// has access to the secret based on resource control (check are done based on the secretID and optional Swarm service ID) // has access to the secret based on resource control (check are done based on the secretID and optional Swarm service ID)
// and either rewrite an access denied response or a decorated secret. // and either rewrite an access denied response or a decorated secret.
func secretInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { func secretInspectOperation(response *http.Response, executor *operationExecutor) error {
// SecretInspect response is a JSON object // SecretInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect // https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect
responseObject, err := getResponseAsJSONOBject(response) responseObject, err := getResponseAsJSONOBject(response)

View file

@ -15,7 +15,7 @@ const (
// serviceListOperation extracts the response as a JSON array, loop through the service array // 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 // 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 { func serviceListOperation(response *http.Response, executor *operationExecutor) error {
var err error var err error
// ServiceList response is a JSON array // ServiceList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList // https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
@ -39,7 +39,7 @@ func serviceListOperation(request *http.Request, response *http.Response, execut
// serviceInspectOperation extracts the response as a JSON object, verify that the user // 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 // has access to the service based on resource control and either rewrite an access denied response
// or a decorated service. // or a decorated service.
func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { func serviceInspectOperation(response *http.Response, executor *operationExecutor) error {
// ServiceInspect response is a JSON object // ServiceInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect // https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
responseObject, err := getResponseAsJSONOBject(response) responseObject, err := getResponseAsJSONOBject(response)

View file

@ -15,7 +15,7 @@ const (
// 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
// and filter the tasks based on resource controls before rewriting the response // and filter the tasks based on resource controls before rewriting the response
func taskListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { func taskListOperation(response *http.Response, executor *operationExecutor) error {
var err error var err error
// TaskList response is a JSON array // TaskList response is a JSON array

View file

@ -17,11 +17,13 @@ var apiVersionRe = regexp.MustCompile(`(/v[0-9]\.[0-9]*)?`)
type ( type (
proxyTransport struct { proxyTransport struct {
dockerTransport *http.Transport dockerTransport *http.Transport
enableSignature bool
ResourceControlService portainer.ResourceControlService ResourceControlService portainer.ResourceControlService
TeamMembershipService portainer.TeamMembershipService TeamMembershipService portainer.TeamMembershipService
RegistryService portainer.RegistryService RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService DockerHubService portainer.DockerHubService
SettingsService portainer.SettingsService SettingsService portainer.SettingsService
SignatureService portainer.DigitalSignatureService
} }
restrictedOperationContext struct { restrictedOperationContext struct {
isAdmin bool isAdmin bool
@ -45,7 +47,7 @@ type (
operationContext *restrictedOperationContext operationContext *restrictedOperationContext
labelBlackList []portainer.Pair labelBlackList []portainer.Pair
} }
restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error restrictedOperationRequest func(*http.Response, *operationExecutor) error
operationRequest func(*http.Request) error operationRequest func(*http.Request) error
) )
@ -61,6 +63,16 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon
path := apiVersionRe.ReplaceAllString(request.URL.Path, "") path := apiVersionRe.ReplaceAllString(request.URL.Path, "")
request.URL.Path = path request.URL.Path = path
if p.enableSignature {
signature, err := p.SignatureService.Sign(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
request.Header.Set(portainer.PortainerAgentPublicKeyHeader, p.SignatureService.EncodedPublicKey())
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
}
switch { switch {
case strings.HasPrefix(path, "/configs"): case strings.HasPrefix(path, "/configs"):
return p.proxyConfigRequest(request) return p.proxyConfigRequest(request)
@ -392,7 +404,7 @@ func (p *proxyTransport) executeRequestAndRewriteResponse(request *http.Request,
return response, err return response, err
} }
err = operation(request, response, executor) err = operation(response, executor)
return response, err return response, err
} }

View file

@ -15,7 +15,7 @@ const (
// 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
// decorate and/or filter the volumes based on resource controls before rewriting the response // decorate and/or filter the volumes based on resource controls before rewriting the response
func volumeListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { func volumeListOperation(response *http.Response, executor *operationExecutor) error {
var err error var err error
// VolumeList response is a JSON object // VolumeList response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeList // https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
@ -48,7 +48,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 any existing 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(response *http.Response, executor *operationExecutor) error {
// VolumeInspect response is a JSON object // VolumeInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect // https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
responseObject, err := getResponseAsJSONOBject(response) responseObject, err := getResponseAsJSONOBject(response)

View file

@ -34,6 +34,7 @@ type Server struct {
StackManager portainer.StackManager StackManager portainer.StackManager
LDAPService portainer.LDAPService LDAPService portainer.LDAPService
GitService portainer.GitService GitService portainer.GitService
SignatureService portainer.DigitalSignatureService
Handler *handler.Handler Handler *handler.Handler
SSL bool SSL bool
SSLCert string SSLCert string
@ -43,7 +44,15 @@ type Server struct {
// Start starts the HTTP server // Start starts the HTTP server
func (server *Server) Start() error { func (server *Server) Start() error {
requestBouncer := security.NewRequestBouncer(server.JWTService, server.UserService, server.TeamMembershipService, server.AuthDisabled) requestBouncer := security.NewRequestBouncer(server.JWTService, server.UserService, server.TeamMembershipService, server.AuthDisabled)
proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService, server.RegistryService, server.DockerHubService) proxyManagerParameters := &proxy.ManagerParams{
ResourceControlService: server.ResourceControlService,
TeamMembershipService: server.TeamMembershipService,
SettingsService: server.SettingsService,
RegistryService: server.RegistryService,
DockerHubService: server.DockerHubService,
SignatureService: server.SignatureService,
}
proxyManager := proxy.NewManager(proxyManagerParameters)
var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public")) var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public"))
var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled) var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled)
@ -78,6 +87,7 @@ func (server *Server) Start() error {
dockerHandler.ProxyManager = proxyManager dockerHandler.ProxyManager = proxyManager
var websocketHandler = handler.NewWebSocketHandler() var websocketHandler = handler.NewWebSocketHandler()
websocketHandler.EndpointService = server.EndpointService websocketHandler.EndpointService = server.EndpointService
websocketHandler.SignatureService = server.SignatureService
var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement) var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement)
endpointHandler.EndpointService = server.EndpointService endpointHandler.EndpointService = server.EndpointService
endpointHandler.EndpointGroupService = server.EndpointGroupService endpointHandler.EndpointGroupService = server.EndpointGroupService

View file

@ -24,6 +24,7 @@ type (
NoAnalytics *bool NoAnalytics *bool
Templates *string Templates *string
TLSVerify *bool TLSVerify *bool
TLSSkipVerify *bool
TLSCacert *string TLSCacert *string
TLSCert *string TLSCert *string
TLSKey *string TLSKey *string
@ -168,11 +169,15 @@ type (
// EndpointID represents an endpoint identifier. // EndpointID represents an endpoint identifier.
EndpointID int EndpointID int
// EndpointType represents the type of an endpoint.
EndpointType int
// Endpoint represents a Docker endpoint with all the info required // Endpoint represents a Docker endpoint with all the info required
// to connect to it. // to connect to it.
Endpoint struct { Endpoint struct {
ID EndpointID `json:"Id"` ID EndpointID `json:"Id"`
Name string `json:"Name"` Name string `json:"Name"`
Type EndpointType `json:"Type"`
URL string `json:"URL"` URL string `json:"URL"`
GroupID EndpointGroupID `json:"GroupId"` GroupID EndpointGroupID `json:"GroupId"`
PublicURL string `json:"PublicURL"` PublicURL string `json:"PublicURL"`
@ -378,6 +383,15 @@ type (
CompareHashAndData(hash string, data string) error CompareHashAndData(hash string, data string) error
} }
// DigitalSignatureService represents a service to manage digital signatures.
DigitalSignatureService interface {
ParseKeyPair(private, public []byte) error
GenerateKeyPair() ([]byte, []byte, error)
EncodedPublicKey() string
PEMHeaders() (string, string)
Sign(message string) (string, error)
}
// JWTService represents a service for managing JWT tokens. // JWTService represents a service for managing JWT tokens.
JWTService interface { JWTService interface {
GenerateToken(data *TokenData) (string, error) GenerateToken(data *TokenData) (string, error)
@ -395,6 +409,10 @@ type (
GetStackProjectPath(stackIdentifier string) string GetStackProjectPath(stackIdentifier string) string
StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error) StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error)
StoreStackFileFromReader(stackIdentifier, fileName string, r io.Reader) (string, error) StoreStackFileFromReader(stackIdentifier, fileName string, r io.Reader) (string, error)
KeyPairFilesExist() (bool, error)
StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error
LoadKeyPair() ([]byte, []byte, error)
WriteJSONToFile(path string, content interface{}) error
} }
// GitService represents a service for managing Git. // GitService represents a service for managing Git.
@ -427,9 +445,20 @@ const (
// APIVersion is the version number of the Portainer API. // APIVersion is the version number of the Portainer API.
APIVersion = "1.16.5" APIVersion = "1.16.5"
// DBVersion is the version number of the Portainer database. // DBVersion is the version number of the Portainer database.
DBVersion = 9 DBVersion = 10
// 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"
// PortainerAgentHeader represents the name of the header available in any agent response
PortainerAgentHeader = "Portainer-Agent"
// PortainerAgentTargetHeader represent the name of the header containing the target node name.
PortainerAgentTargetHeader = "X-PortainerAgent-Target"
// PortainerAgentSignatureHeader represent the name of the header containing the digital signature
PortainerAgentSignatureHeader = "X-PortainerAgent-Signature"
// PortainerAgentPublicKeyHeader represent the name of the header containing the public key
PortainerAgentPublicKeyHeader = "X-PortainerAgent-PublicKey"
// PortainerAgentSignatureMessage represents the message used to create a digital signature
// to be used when communicating with an agent
PortainerAgentSignatureMessage = "Portainer-App"
) )
const ( const (
@ -494,3 +523,11 @@ const (
// StoridgeEndpointExtension represents the Storidge extension // StoridgeEndpointExtension represents the Storidge extension
StoridgeEndpointExtension StoridgeEndpointExtension
) )
const (
_ EndpointType = iota
// DockerEnvironment represents an endpoint connected to a Docker environment
DockerEnvironment
// AgentOnDockerEnvironment represents an endpoint connected to a Portainer agent deployed on a Docker environment
AgentOnDockerEnvironment
)

View file

@ -238,7 +238,7 @@ paths:
200: 200:
description: "Success" description: "Success"
schema: schema:
$ref: "#/definitions/EndpointCreateResponse" $ref: "#/definitions/Endpoint"
400: 400:
description: "Invalid request" description: "Invalid request"
schema: schema:

View file

@ -17,6 +17,7 @@ angular.module('portainer', [
'luegg.directives', 'luegg.directives',
'portainer.templates', 'portainer.templates',
'portainer.app', 'portainer.app',
'portainer.agent',
'portainer.docker', 'portainer.docker',
'extension.storidge', 'extension.storidge',
'rzModule']); 'rzModule']);

1
app/agent/_module.js Normal file
View file

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

View file

@ -0,0 +1,7 @@
angular.module('portainer.agent').component('nodeSelector', {
templateUrl: 'app/agent/components/node-selector/nodeSelector.html',
controller: 'NodeSelectorController',
bindings: {
model: '='
}
});

View file

@ -0,0 +1,8 @@
<div class="form-group">
<label for="target_node" class="col-sm-1 control-label text-left">Node</label>
<div class="col-sm-11">
<select class="form-control"
ng-model="$ctrl.model" ng-options="agent.NodeName as agent.NodeName for agent in $ctrl.agents"
></select>
</div>
</div>

View file

@ -0,0 +1,18 @@
angular.module('portainer.agent')
.controller('NodeSelectorController', ['AgentService', 'Notifications', function (AgentService, Notifications) {
var ctrl = this;
this.$onInit = function() {
AgentService.agents()
.then(function success(data) {
ctrl.agents = data;
if (!ctrl.model) {
ctrl.model = data[0].NodeName;
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to load agents');
});
};
}]);

View file

@ -0,0 +1,5 @@
function AgentViewModel(data) {
this.IPAddress = data.IPAddress;
this.NodeName = data.NodeName;
this.NodeRole = data.NodeRole;
}

10
app/agent/rest/agent.js Normal file
View file

@ -0,0 +1,10 @@
angular.module('portainer.agent')
.factory('Agent', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/agents', {
endpointId: EndpointProvider.endpointID
},
{
query: {method: 'GET', isArray: true}
});
}]);

View file

@ -0,0 +1,24 @@
angular.module('portainer.agent')
.factory('AgentService', ['$q', 'Agent', function AgentServiceFactory($q, Agent) {
'use strict';
var service = {};
service.agents = function() {
var deferred = $q.defer();
Agent.query({}).$promise
.then(function success(data) {
var agents = data.map(function (item) {
return new AgentViewModel(item);
});
deferred.resolve(agents);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve agents', err: err });
});
return deferred.promise;
};
return service;
}]);

View file

@ -1,5 +1,6 @@
angular.module('portainer') angular.module('portainer')
.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'cfpLoadingBar', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, cfpLoadingBar) { .run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper',
function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, cfpLoadingBar, $transitions, HttpRequestHelper) {
'use strict'; 'use strict';
EndpointProvider.initialize(); EndpointProvider.initialize();
@ -27,6 +28,10 @@ angular.module('portainer')
originalSet.apply(cfpLoadingBar, arguments); originalSet.apply(cfpLoadingBar, arguments);
} }
}; };
$transitions.onBefore({ to: 'docker.**' }, function() {
HttpRequestHelper.resetAgentTargetQueue();
});
}]); }]);

View file

@ -24,6 +24,17 @@ angular.module('portainer')
$httpProvider.defaults.headers.put['Content-Type'] = 'application/json'; $httpProvider.defaults.headers.put['Content-Type'] = 'application/json';
$httpProvider.defaults.headers.patch['Content-Type'] = 'application/json'; $httpProvider.defaults.headers.patch['Content-Type'] = 'application/json';
$httpProvider.interceptors.push(['HttpRequestHelper', function(HttpRequestHelper) {
return {
request: function(config) {
if (config.url.indexOf('/docker/') > -1) {
config.headers['X-PortainerAgent-Target'] = HttpRequestHelper.portainerAgentTargetHeader();
}
return config;
}
};
}]);
AnalyticsProvider.setAccount('@@CONFIG_GA_ID'); AnalyticsProvider.setAccount('@@CONFIG_GA_ID');
AnalyticsProvider.startOffline(true); AnalyticsProvider.startOffline(true);

View file

@ -57,7 +57,7 @@ angular.module('portainer.docker', ['portainer.app'])
var container = { var container = {
name: 'docker.containers.container', name: 'docker.containers.container',
url: '/:id', url: '/:id?nodeName',
views: { views: {
'content@': { 'content@': {
templateUrl: 'app/docker/views/containers/edit/container.html', templateUrl: 'app/docker/views/containers/edit/container.html',
@ -79,15 +79,12 @@ angular.module('portainer.docker', ['portainer.app'])
var containerCreation = { var containerCreation = {
name: 'docker.containers.new', name: 'docker.containers.new',
url: '/new', url: '/new?nodeName&from',
views: { views: {
'content@': { 'content@': {
templateUrl: 'app/docker/views/containers/create/createcontainer.html', templateUrl: 'app/docker/views/containers/create/createcontainer.html',
controller: 'CreateContainerController' controller: 'CreateContainerController'
} }
},
params: {
from: ''
} }
}; };
@ -170,7 +167,7 @@ angular.module('portainer.docker', ['portainer.app'])
var image = { var image = {
name: 'docker.images.image', name: 'docker.images.image',
url: '/:id', url: '/:id?nodeName',
views: { views: {
'content@': { 'content@': {
templateUrl: 'app/docker/views/images/edit/image.html', templateUrl: 'app/docker/views/images/edit/image.html',
@ -203,7 +200,7 @@ angular.module('portainer.docker', ['portainer.app'])
var network = { var network = {
name: 'docker.networks.network', name: 'docker.networks.network',
url: '/:id', url: '/:id?nodeName',
views: { views: {
'content@': { 'content@': {
templateUrl: 'app/docker/views/networks/edit/network.html', templateUrl: 'app/docker/views/networks/edit/network.html',
@ -443,7 +440,7 @@ angular.module('portainer.docker', ['portainer.app'])
var volume = { var volume = {
name: 'docker.volumes.volume', name: 'docker.volumes.volume',
url: '/:id', url: '/:id?nodeName',
views: { views: {
'content@': { 'content@': {
templateUrl: 'app/docker/views/volumes/edit/volume.html', templateUrl: 'app/docker/views/volumes/edit/volume.html',

View file

@ -0,0 +1,4 @@
angular.module('portainer.docker').component('dashboardClusterAgentInfo', {
templateUrl: 'app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html',
controller: 'DashboardClusterAgentInfoController'
});

View file

@ -0,0 +1,20 @@
<rd-widget>
<rd-widget-header icon="fa-tachometer-alt" title="Cluster information"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Nodes in the cluster</td>
<td>{{ $ctrl.agentCount }}</td>
</tr>
<tr>
<td colspan="2">
<div class="btn-group" role="group" aria-label="...">
<a ui-sref="docker.swarm.visualizer"><i class="fa fa-object-group space-right" aria-hidden="true"></i>Go to cluster visualizer</a>
</div>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>

View file

@ -0,0 +1,16 @@
angular.module('portainer.docker')
.controller('DashboardClusterAgentInfoController', ['AgentService', 'Notifications',
function (AgentService, Notifications) {
var ctrl = this;
this.$onInit = function() {
AgentService.agents()
.then(function success(data) {
ctrl.agentCount = data.length;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve agent information');
});
};
}]);

View file

@ -38,7 +38,7 @@
</thead> </thead>
<tbody> <tbody>
<tr dir-paginate="(key, value) in $ctrl.dataset | itemsPerPage: $ctrl.state.paginatedItemLimit" ng-class="{active: item.Checked}"> <tr dir-paginate="(key, value) in $ctrl.dataset | itemsPerPage: $ctrl.state.paginatedItemLimit" ng-class="{active: item.Checked}">
<td><a ui-sref="docker.networks.network({id: value.NetworkID})">{{ key }}</a></td> <td><a ui-sref="docker.networks.network({ id: value.NetworkID, nodeName: $ctrl.nodeName })">{{ key }}</a></td>
<td>{{ value.IPAddress || '-' }}</td> <td>{{ value.IPAddress || '-' }}</td>
<td>{{ value.Gateway || '-' }}</td> <td>{{ value.Gateway || '-' }}</td>
<td>{{ value.MacAddress || '-' }}</td> <td>{{ value.MacAddress || '-' }}</td>

View file

@ -11,6 +11,7 @@ angular.module('portainer.docker').component('containerNetworksDatatable', {
joinNetworkAction: '<', joinNetworkAction: '<',
joinNetworkActionInProgress: '<', joinNetworkActionInProgress: '<',
leaveNetworkActionInProgress: '<', leaveNetworkActionInProgress: '<',
leaveNetworkAction: '<' leaveNetworkAction: '<',
nodeName: '<'
} }
}); });

View file

@ -156,6 +156,13 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'IP' && $ctrl.state.reverseOrder"></i> <i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'IP' && $ctrl.state.reverseOrder"></i>
</a> </a>
</th> </th>
<th ng-if="$ctrl.showHostColumn">
<a ng-click="$ctrl.changeOrderBy('NodeName')">
Host
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th> <th>
<a ng-click="$ctrl.changeOrderBy('Ports')"> <a ng-click="$ctrl.changeOrderBy('Ports')">
Published Ports Published Ports
@ -179,7 +186,7 @@
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/> <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
<label for="select_{{ $index }}"></label> <label for="select_{{ $index }}"></label>
</span> </span>
<a ui-sref="docker.containers.container({ id: item.Id })">{{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }}</a> <a ui-sref="docker.containers.container({ id: item.Id, nodeName: item.NodeName })">{{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }}</a>
</td> </td>
<td> <td>
<span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) !== -1" class="label label-{{ item.Status|containerstatusbadge }} interactive" uib-tooltip="This container has a health check">{{ item.Status }}</span> <span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) !== -1" class="label label-{{ item.Status|containerstatusbadge }} interactive" uib-tooltip="This container has a health check">{{ item.Status }}</span>
@ -187,15 +194,16 @@
</td> </td>
<td ng-if="$ctrl.settings.showQuickActionStats || $ctrl.settings.showQuickActionLogs || $ctrl.settings.showQuickActionConsole || $ctrl.settings.showQuickActionInspect"> <td ng-if="$ctrl.settings.showQuickActionStats || $ctrl.settings.showQuickActionLogs || $ctrl.settings.showQuickActionConsole || $ctrl.settings.showQuickActionInspect">
<div class="btn-group btn-group-xs" role="group" aria-label="..." style="display:inline-flex;"> <div class="btn-group btn-group-xs" role="group" aria-label="..." style="display:inline-flex;">
<a ng-if="$ctrl.settings.showQuickActionStats" style="margin: 0 2.5px;" ui-sref="docker.containers.container.stats({id: item.Id})" title="Stats"><i class="fa fa-chart-area space-right" aria-hidden="true"></i></a> <a ng-if="$ctrl.settings.showQuickActionStats" style="margin: 0 2.5px;" ui-sref="docker.containers.container.stats({id: item.Id, nodeName: item.NodeName})" title="Stats"><i class="fa fa-chart-area space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionLogs" style="margin: 0 2.5px;" ui-sref="docker.containers.container.logs({id: item.Id})" title="Logs"><i class="fa fa-file-alt space-right" aria-hidden="true"></i></a> <a ng-if="$ctrl.settings.showQuickActionLogs" style="margin: 0 2.5px;" ui-sref="docker.containers.container.logs({id: item.Id, nodeName: item.NodeName})" title="Logs"><i class="fa fa-file-alt space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionConsole" style="margin: 0 2.5px;" ui-sref="docker.containers.container.console({id: item.Id})" title="Console"><i class="fa fa-terminal space-right" aria-hidden="true"></i></a> <a ng-if="$ctrl.settings.showQuickActionConsole" style="margin: 0 2.5px;" ui-sref="docker.containers.container.console({id: item.Id, nodeName: item.NodeName})" title="Console"><i class="fa fa-terminal space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionInspect" style="margin: 0 2.5px;" ui-sref="docker.containers.container.inspect({id: item.Id})" title="Inspect"><i class="fa fa-info-circle space-right" aria-hidden="true"></i></a> <a ng-if="$ctrl.settings.showQuickActionInspect" style="margin: 0 2.5px;" ui-sref="docker.containers.container.inspect({id: item.Id, nodeName: item.NodeName})" title="Inspect"><i class="fa fa-info-circle space-right" aria-hidden="true"></i></a>
</div> </div>
</td> </td>
<td>{{ item.StackName ? item.StackName : '-' }}</td> <td>{{ item.StackName ? item.StackName : '-' }}</td>
<td><a ui-sref="docker.images.image({ id: item.Image })">{{ item.Image | trimshasum }}</a></td> <td><a ui-sref="docker.images.image({ id: item.Image })">{{ item.Image | trimshasum }}</a></td>
<td>{{ item.IP ? item.IP : '-' }}</td> <td>{{ item.IP ? item.IP : '-' }}</td>
<td ng-if="$ctrl.showHostColumn">{{ item.NodeName ? item.NodeName : '-' }}</td>
<td> <td>
<a ng-if="item.Ports.length > 0" ng-repeat="p in item.Ports" class="image-tag" ng-href="http://{{ $ctrl.publicUrl || p.host }}:{{p.public}}" target="_blank"> <a ng-if="item.Ports.length > 0" ng-repeat="p in item.Ports" class="image-tag" ng-href="http://{{ $ctrl.publicUrl || p.host }}:{{p.public}}" target="_blank">
<i class="fa fa-external-link-alt" aria-hidden="true"></i> {{ p.public }}:{{ p.private }} <i class="fa fa-external-link-alt" aria-hidden="true"></i> {{ p.public }}:{{ p.private }}
@ -210,10 +218,10 @@
</td> </td>
</tr> </tr>
<tr ng-if="!$ctrl.dataset"> <tr ng-if="!$ctrl.dataset">
<td colspan="8" class="text-center text-muted">Loading...</td> <td colspan="9" class="text-center text-muted">Loading...</td>
</tr> </tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0"> <tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="8" class="text-center text-muted">No container available.</td> <td colspan="9" class="text-center text-muted">No container available.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View file

@ -10,6 +10,7 @@ angular.module('portainer.docker').component('containersDatatable', {
reverseOrder: '<', reverseOrder: '<',
showTextFilter: '<', showTextFilter: '<',
showOwnershipColumn: '<', showOwnershipColumn: '<',
showHostColumn: '<',
publicUrl: '<', publicUrl: '<',
containerNameTruncateSize: '<', containerNameTruncateSize: '<',
startAction: '<', startAction: '<',

View file

@ -93,6 +93,13 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && $ctrl.state.reverseOrder"></i> <i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && $ctrl.state.reverseOrder"></i>
</a> </a>
</th> </th>
<th ng-if="$ctrl.showHostColumn">
<a ng-click="$ctrl.changeOrderBy('NodeName')">
Host
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -102,7 +109,7 @@
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/> <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
<label for="select_{{ $index }}"></label> <label for="select_{{ $index }}"></label>
</span> </span>
<a ui-sref="docker.images.image({id: item.Id})" class="monospaced">{{ item.Id | truncate:20 }}</a> <a ui-sref="docker.images.image({ id: item.Id, nodeName: item.NodeName })" class="monospaced">{{ item.Id | truncate:20 }}</a>
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="::item.ContainerCount === 0">Unused</span> <span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="::item.ContainerCount === 0">Unused</span>
</td> </td>
<td> <td>
@ -110,12 +117,13 @@
</td> </td>
<td>{{ item.VirtualSize | humansize }}</td> <td>{{ item.VirtualSize | humansize }}</td>
<td>{{ item.Created | getisodatefromtimestamp }}</td> <td>{{ item.Created | getisodatefromtimestamp }}</td>
<td ng-if="$ctrl.showHostColumn">{{ item.NodeName ? item.NodeName : '-' }}</td>
</tr> </tr>
<tr ng-if="!$ctrl.dataset"> <tr ng-if="!$ctrl.dataset">
<td colspan="4" class="text-center text-muted">Loading...</td> <td colspan="5" class="text-center text-muted">Loading...</td>
</tr> </tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0"> <tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="4" class="text-center text-muted">No image available.</td> <td colspan="5" class="text-center text-muted">No image available.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View file

@ -9,6 +9,7 @@ angular.module('portainer.docker').component('imagesDatatable', {
orderBy: '@', orderBy: '@',
reverseOrder: '<', reverseOrder: '<',
showTextFilter: '<', showTextFilter: '<',
showHostColumn: '<',
removeAction: '<', removeAction: '<',
forceRemoveAction: '<' forceRemoveAction: '<'
} }

View file

@ -81,6 +81,13 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'IPAM.Config[0].Gateway' && $ctrl.state.reverseOrder"></i> <i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'IPAM.Config[0].Gateway' && $ctrl.state.reverseOrder"></i>
</a> </a>
</th> </th>
<th ng-if="$ctrl.showHostColumn">
<a ng-click="$ctrl.changeOrderBy('NodeName')">
Host
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th ng-if="$ctrl.showOwnershipColumn"> <th ng-if="$ctrl.showOwnershipColumn">
<a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')"> <a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')">
Ownership Ownership
@ -97,7 +104,7 @@
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/> <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
<label for="select_{{ $index }}"></label> <label for="select_{{ $index }}"></label>
</span> </span>
<a ui-sref="docker.networks.network({id: item.Id})" title="{{ item.Name }}">{{ item.Name | truncate:40 }}</a> <a ui-sref="docker.networks.network({ id: item.Id, nodeName: item.NodeName })" title="{{ item.Name }}">{{ item.Name | truncate:40 }}</a>
</td> </td>
<td>{{ item.StackName ? item.StackName : '-' }}</td> <td>{{ item.StackName ? item.StackName : '-' }}</td>
<td>{{ item.Scope }}</td> <td>{{ item.Scope }}</td>
@ -105,6 +112,7 @@
<td>{{ item.IPAM.Driver }}</td> <td>{{ item.IPAM.Driver }}</td>
<td>{{ item.IPAM.Config[0].Subnet ? item.IPAM.Config[0].Subnet : '-' }}</td> <td>{{ item.IPAM.Config[0].Subnet ? item.IPAM.Config[0].Subnet : '-' }}</td>
<td>{{ item.IPAM.Config[0].Gateway ? item.IPAM.Config[0].Gateway : '-' }}</td> <td>{{ item.IPAM.Config[0].Gateway ? item.IPAM.Config[0].Gateway : '-' }}</td>
<td ng-if="$ctrl.showHostColumn">{{ item.NodeName ? item.NodeName : '-' }}</td>
<td ng-if="$ctrl.showOwnershipColumn"> <td ng-if="$ctrl.showOwnershipColumn">
<span> <span>
<i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i> <i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
@ -113,10 +121,10 @@
</td> </td>
</tr> </tr>
<tr ng-if="!$ctrl.dataset"> <tr ng-if="!$ctrl.dataset">
<td colspan="8" class="text-center text-muted">Loading...</td> <td colspan="9" class="text-center text-muted">Loading...</td>
</tr> </tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0"> <tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="8" class="text-center text-muted">No network available.</td> <td colspan="9" class="text-center text-muted">No network available.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View file

@ -10,6 +10,7 @@ angular.module('portainer.docker').component('networksDatatable', {
reverseOrder: '<', reverseOrder: '<',
showTextFilter: '<', showTextFilter: '<',
showOwnershipColumn: '<', showOwnershipColumn: '<',
showHostColumn: '<',
removeAction: '<' removeAction: '<'
} }
}); });

View file

@ -54,20 +54,26 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Updated' && $ctrl.state.reverseOrder"></i> <i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Updated' && $ctrl.state.reverseOrder"></i>
</a> </a>
</th> </th>
<th ng-if="$ctrl.showLogsButton">Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}"> <tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
<td><a ui-sref="docker.tasks.task({id: item.Id})" class="monospaced">{{ item.Id }}</a></td> <td>
<a ng-if="!$ctrl.agentProxy || !item.Container" ui-sref="docker.tasks.task({id: item.Id})" class="monospaced">{{ item.Id }}</a>
<a ng-if="$ctrl.agentProxy && item.Container" ui-sref="docker.containers.container({ id: item.Container.Id, nodeName: item.Container.NodeName })" class="monospaced">{{ item.Id }}</a>
</td>
<td><span class="label label-{{ item.Status.State | taskstatusbadge }}">{{ item.Status.State }}</span></td> <td><span class="label label-{{ item.Status.State | taskstatusbadge }}">{{ item.Status.State }}</span></td>
<td ng-if="$ctrl.showSlotColumn">{{ item.Slot ? item.Slot : '-' }}</td> <td ng-if="$ctrl.showSlotColumn">{{ item.Slot ? item.Slot : '-' }}</td>
<td>{{ item.NodeId | tasknodename: $ctrl.nodes }}</td> <td>{{ item.NodeId | tasknodename: $ctrl.nodes }}</td>
<td>{{ item.Updated | getisodate }}</td> <td>{{ item.Updated | getisodate }}</td>
<td ng-if="$ctrl.showLogsButton"> <td>
<a ui-sref="docker.tasks.task.logs({id: item.Id})"> <a ui-sref="docker.tasks.task.logs({id: item.Id})" ng-if="$ctrl.showLogsButton" class="space-right">
<i class="fa fa-file-alt" aria-hidden="true"></i> View logs <i class="fa fa-file-alt" aria-hidden="true"></i> View logs
</a> </a>
<a ui-sref="docker.containers.container.console({ id: item.Container.Id, nodeName: item.Container.NodeName })" ng-if="$ctrl.agentProxy">
<i class="fa fa-terminal" aria-hidden="true"></i> Console
</a>
</td> </td>
</tr> </tr>
<tr ng-if="!$ctrl.dataset"> <tr ng-if="!$ctrl.dataset">

View file

@ -11,6 +11,7 @@ angular.module('portainer.docker').component('tasksDatatable', {
nodes: '<', nodes: '<',
showTextFilter: '<', showTextFilter: '<',
showSlotColumn: '<', showSlotColumn: '<',
showLogsButton: '<' showLogsButton: '<',
agentProxy: '<'
} }
}); });

View file

@ -84,6 +84,13 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Mountpoint' && $ctrl.state.reverseOrder"></i> <i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Mountpoint' && $ctrl.state.reverseOrder"></i>
</a> </a>
</th> </th>
<th ng-if="$ctrl.showHostColumn">
<a ng-click="$ctrl.changeOrderBy('NodeName')">
Host
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th ng-if="$ctrl.showOwnershipColumn"> <th ng-if="$ctrl.showOwnershipColumn">
<a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')"> <a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')">
Ownership Ownership
@ -100,12 +107,13 @@
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/> <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
<label for="select_{{ $index }}"></label> <label for="select_{{ $index }}"></label>
</span> </span>
<a ui-sref="docker.volumes.volume({id: item.Id})" class="monospaced">{{ item.Id | truncate:25 }}</a> <a ui-sref="docker.volumes.volume({ id: item.Id, nodeName: item.NodeName })" class="monospaced">{{ item.Id | truncate:25 }}</a>
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="item.dangling">Unused</span> <span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="item.dangling">Unused</span>
</td> </td>
<td>{{ item.StackName ? item.StackName : '-' }}</td> <td>{{ item.StackName ? item.StackName : '-' }}</td>
<td>{{ item.Driver }}</td> <td>{{ item.Driver }}</td>
<td>{{ item.Mountpoint | truncatelr }}</td> <td>{{ item.Mountpoint | truncatelr }}</td>
<td ng-if="$ctrl.showHostColumn">{{ item.NodeName ? item.NodeName : '-' }}</td>
<td ng-if="$ctrl.showOwnershipColumn"> <td ng-if="$ctrl.showOwnershipColumn">
<span> <span>
<i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i> <i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
@ -114,10 +122,10 @@
</td> </td>
</tr> </tr>
<tr ng-if="!$ctrl.dataset"> <tr ng-if="!$ctrl.dataset">
<td colspan="5" class="text-center text-muted">Loading...</td> <td colspan="6" class="text-center text-muted">Loading...</td>
</tr> </tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0"> <tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="5" class="text-center text-muted">No volume available.</td> <td colspan="6" class="text-center text-muted">No volume available.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View file

@ -10,6 +10,7 @@ angular.module('portainer.docker').component('volumesDatatable', {
reverseOrder: '<', reverseOrder: '<',
showTextFilter: '<', showTextFilter: '<',
showOwnershipColumn: '<', showOwnershipColumn: '<',
showHostColumn: '<',
removeAction: '<' removeAction: '<'
} }
}); });

View file

@ -4,12 +4,20 @@ angular.module('portainer.docker')
var helper = {}; var helper = {};
helper.determineEndpointMode = function(info) { helper.determineEndpointMode = function(info, type) {
var mode = { var mode = {
provider: '', provider: '',
role: '' role: '',
agentProxy: false
}; };
if (type === 2) {
mode.provider = 'DOCKER_SWARM_MODE';
mode.role = 'MANAGER';
mode.agentProxy = true;
return mode;
}
if (!info.Swarm || _.isEmpty(info.Swarm.NodeID)) { if (!info.Swarm || _.isEmpty(info.Swarm.NodeID)) {
if (info.ID === 'vSphere Integrated Containers') { if (info.ID === 'vSphere Integrated Containers') {
mode.provider = 'VMWARE_VIC'; mode.provider = 'VMWARE_VIC';

View file

@ -0,0 +1,18 @@
angular.module('portainer.docker')
.factory('TaskHelper', [function TaskHelperFactory() {
'use strict';
var helper = {};
helper.associateContainerToTask = function(task, containers) {
for (var i = 0; i < containers.length; i++) {
var container = containers[i];
if (task.ContainerId === container.Id) {
task.Container = container;
break;
}
}
};
return helper;
}]);

View file

@ -34,6 +34,9 @@ function ContainerViewModel(data) {
if (data.Portainer.ResourceControl) { if (data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
} }
if (data.Portainer.Agent && data.Portainer.Agent.NodeName) {
this.NodeName = data.Portainer.Agent.NodeName;
}
} }
} }

View file

@ -7,6 +7,10 @@ function ImageViewModel(data) {
this.RepoTags = data.RepoTags; this.RepoTags = data.RepoTags;
this.VirtualSize = data.VirtualSize; this.VirtualSize = data.VirtualSize;
this.ContainerCount = data.ContainerCount; this.ContainerCount = data.ContainerCount;
if (data.Portainer && data.Portainer.Agent && data.Portainer.Agent.NodeName) {
this.NodeName = data.Portainer.Agent.NodeName;
}
} }
function ImageBuildModel(data) { function ImageBuildModel(data) {

View file

@ -19,5 +19,8 @@ function NetworkViewModel(data) {
if (data.Portainer.ResourceControl) { if (data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
} }
if (data.Portainer.Agent && data.Portainer.Agent.NodeName) {
this.NodeName = data.Portainer.Agent.NodeName;
}
} }
} }

View file

@ -7,4 +7,7 @@ function TaskViewModel(data) {
this.Status = data.Status; this.Status = data.Status;
this.ServiceId = data.ServiceID; this.ServiceId = data.ServiceID;
this.NodeId = data.NodeID; this.NodeId = data.NodeID;
if (data.Status && data.Status.ContainerStatus && data.Status.ContainerStatus.ContainerID) {
this.ContainerId = data.Status.ContainerStatus.ContainerID;
}
} }

View file

@ -14,5 +14,8 @@ function VolumeViewModel(data) {
if (data.Portainer.ResourceControl) { if (data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
} }
if (data.Portainer.Agent && data.Portainer.Agent.NodeName) {
this.NodeName = data.Portainer.Agent.NodeName;
}
} }
} }

View file

@ -1,18 +1,34 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { .factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider',
function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict'; 'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/containers/:id/:action', { return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/containers/:id/:action', {
name: '@name', name: '@name',
endpointId: EndpointProvider.endpointID endpointId: EndpointProvider.endpointID
}, },
{ {
query: {method: 'GET', params: {all: 0, action: 'json', filters: '@filters' }, isArray: true}, query: {
get: {method: 'GET', params: {action: 'json'}}, method: 'GET', params: { all: 0, action: 'json', filters: '@filters' },
stop: {method: 'POST', params: {id: '@id', t: 5, action: 'stop'}}, isArray: true
restart: {method: 'POST', params: {id: '@id', t: 5, action: 'restart'}}, },
kill: {method: 'POST', params: {id: '@id', action: 'kill'}}, get: {
pause: {method: 'POST', params: {id: '@id', action: 'pause'}}, method: 'GET', params: { action: 'json' }
unpause: {method: 'POST', params: {id: '@id', action: 'unpause'}}, },
stop: {
method: 'POST', params: { id: '@id', t: 5, action: 'stop' }
},
restart: {
method: 'POST', params: { id: '@id', t: 5, action: 'restart' }
},
kill: {
method: 'POST', params: { id: '@id', action: 'kill' }
},
pause: {
method: 'POST', params: { id: '@id', action: 'pause' }
},
unpause: {
method: 'POST', params: { id: '@id', action: 'unpause' }
},
logs: { logs: {
method: 'GET', params: { id: '@id', action: 'logs' }, method: 'GET', params: { id: '@id', action: 'logs' },
timeout: 4500, ignoreLoadingBar: true, timeout: 4500, ignoreLoadingBar: true,
@ -40,7 +56,7 @@ angular.module('portainer.docker')
transformResponse: genericHandler transformResponse: genericHandler
}, },
rename: { rename: {
method: 'POST', params: {id: '@id', action: 'rename', name: '@name'}, method: 'POST', params: { id: '@id', action: 'rename', name: '@name' },
transformResponse: genericHandler transformResponse: genericHandler
}, },
exec: { exec: {

View file

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

View file

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

View file

@ -1,16 +1,30 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.factory('Network', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { .factory('Network', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider',
function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict'; 'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/networks/:id/:action', { return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/networks/:id/:action', {
id: '@id', id: '@id',
endpointId: EndpointProvider.endpointID endpointId: EndpointProvider.endpointID
}, },
{ {
query: {method: 'GET', isArray: true}, query: {
get: {method: 'GET'}, method: 'GET', isArray: true
create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler, ignoreLoadingBar: true}, },
remove: { method: 'DELETE', transformResponse: genericHandler }, get: {
connect: {method: 'POST', params: {action: 'connect'}}, method: 'GET'
disconnect: {method: 'POST', params: {action: 'disconnect'}} },
create: {
method: 'POST', params: {action: 'create'},
transformResponse: genericHandler, ignoreLoadingBar: true
},
remove: {
method: 'DELETE', transformResponse: genericHandler
},
connect: {
method: 'POST', params: { action: 'connect' }
},
disconnect: {
method: 'POST', params: { action: 'disconnect' }
}
}); });
}]); }]);

View file

@ -1,5 +1,6 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.factory('Service', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper' ,function ServiceFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) { .factory('Service', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper',
function ServiceFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) {
'use strict'; 'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/services/:id/:action', { return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/services/:id/:action', {
endpointId: EndpointProvider.endpointID endpointId: EndpointProvider.endpointID

View file

@ -6,7 +6,10 @@ angular.module('portainer.docker')
endpointId: EndpointProvider.endpointID endpointId: EndpointProvider.endpointID
}, },
{ {
info: { method: 'GET', params: { action: 'info' }, ignoreLoadingBar: true }, info: {
method: 'GET', params: { action: 'info' },
ignoreLoadingBar: true
},
version: { method: 'GET', params: { action: 'version' }, ignoreLoadingBar: true, timeout: 4500 }, version: { method: 'GET', params: { action: 'version' }, ignoreLoadingBar: true, timeout: 4500 },
events: { events: {
method: 'GET', params: { action: 'events', since: '@since', until: '@until' }, method: 'GET', params: { action: 'events', since: '@since', until: '@until' },

View file

@ -35,6 +35,34 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe
return deferred.promise; return deferred.promise;
}; };
service.startContainer = function(id) {
return Container.start({ id: id }, {}).$promise;
};
service.stopContainer = function(id) {
return Container.stop({ id: id }, {}).$promise;
};
service.restartContainer = function(id) {
return Container.restart({ id: id }, {}).$promise;
};
service.killContainer = function(id) {
return Container.kill({ id: id }, {}).$promise;
};
service.pauseContainer = function(id) {
return Container.pause({ id: id }, {}).$promise;
};
service.resumeContainer = function(id) {
return Container.unpause({ id: id }, {}).$promise;
};
service.renameContainer = function(id, newContainerName) {
return Container.rename({id: id, name: newContainerName }, {}).$promise;
};
service.createContainer = function(configuration) { service.createContainer = function(configuration) {
var deferred = $q.defer(); var deferred = $q.defer();
Container.create(configuration).$promise Container.create(configuration).$promise
@ -51,30 +79,6 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe
return deferred.promise; return deferred.promise;
}; };
service.startContainer = function(containerID) {
return Container.start({ id: containerID }, {}).$promise;
};
service.stopContainer = function(containerID) {
return Container.stop({ id: containerID }, {}).$promise;
};
service.restartContainer = function(containerID) {
return Container.restart({ id: containerID }, {}).$promise;
};
service.killContainer = function(containerID) {
return Container.kill({ id: containerID }, {}).$promise;
};
service.pauseContainer = function(containerID) {
return Container.pause({ id: containerID }, {}).$promise;
};
service.resumeContainer = function(containerID) {
return Container.unpause({ id: containerID }, {}).$promise;
};
service.createAndStartContainer = function(configuration) { service.createAndStartContainer = function(configuration) {
var deferred = $q.defer(); var deferred = $q.defer();
var containerID; var containerID;
@ -95,7 +99,7 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe
service.remove = function(container, removeVolumes) { service.remove = function(container, removeVolumes) {
var deferred = $q.defer(); var deferred = $q.defer();
Container.remove({id: container.Id, v: (removeVolumes) ? 1 : 0, force: true}).$promise Container.remove({ id: container.Id, v: (removeVolumes) ? 1 : 0, force: true }).$promise
.then(function success(data) { .then(function success(data) {
if (data.message) { if (data.message) {
deferred.reject({ msg: data.message, err: data.message }); deferred.reject({ msg: data.message, err: data.message });
@ -117,7 +121,7 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe
service.createExec = function(execConfig) { service.createExec = function(execConfig) {
var deferred = $q.defer(); var deferred = $q.defer();
Container.exec(execConfig).$promise Container.exec({}, execConfig).$promise
.then(function success(data) { .then(function success(data) {
if (data.message) { if (data.message) {
deferred.reject({ msg: data.message, err: data.message }); deferred.reject({ msg: data.message, err: data.message });
@ -158,7 +162,7 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe
service.containerStats = function(id) { service.containerStats = function(id) {
var deferred = $q.defer(); var deferred = $q.defer();
Container.stats({id: id}).$promise Container.stats({ id: id }).$promise
.then(function success(data) { .then(function success(data) {
var containerStats = new ContainerStatsViewModel(data); var containerStats = new ContainerStatsViewModel(data);
deferred.resolve(containerStats); deferred.resolve(containerStats);
@ -171,11 +175,11 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe
}; };
service.containerTop = function(id) { service.containerTop = function(id) {
return Container.top({id: id}).$promise; return Container.top({ id: id }).$promise;
}; };
service.inspect = function(id) { service.inspect = function(id) {
return Container.inspect({id: id}).$promise; return Container.inspect({ id: id }).$promise;
}; };
return service; return service;

View file

@ -7,7 +7,7 @@ angular.module('portainer.docker')
var deferred = $q.defer(); var deferred = $q.defer();
$timeout(function() { $timeout(function() {
Exec.resize({id: execId, height: height, width: width}).$promise Exec.resize({}, { id: execId, height: height, width: width }).$promise
.then(function success(data) { .then(function success(data) {
if (data.message) { if (data.message) {
deferred.reject({ msg: 'Unable to exec into container', err: data.message }); deferred.reject({ msg: 'Unable to exec into container', err: data.message });

View file

@ -19,7 +19,7 @@ angular.module('portainer.docker')
service.network = function(id) { service.network = function(id) {
var deferred = $q.defer(); var deferred = $q.defer();
Network.get({id: id}).$promise Network.get({ id: id }).$promise
.then(function success(data) { .then(function success(data) {
var network = new NetworkViewModel(data); var network = new NetworkViewModel(data);
deferred.resolve(network); deferred.resolve(network);
@ -65,5 +65,13 @@ angular.module('portainer.docker')
return Network.remove({ id: id }).$promise; return Network.remove({ id: id }).$promise;
}; };
service.disconnectContainer = function(networkId, containerId, force) {
return Network.disconnect({ id: networkId }, { Container: containerId, Force: force }).$promise;
};
service.connectContainer = function(networkId, containerId) {
return Network.connect({ id: networkId }, { Container: containerId }).$promise;
};
return service; return service;
}]); }]);

View file

@ -1,6 +1,8 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('ContainerConsoleController', ['$scope', '$transition$', 'Container', 'Image', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ContainerService', 'ExecService', .controller('ContainerConsoleController', ['$scope', '$transition$', 'ContainerService', 'ImageService', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ExecService', 'HttpRequestHelper',
function ($scope, $transition$, Container, Image, EndpointProvider, Notifications, ContainerHelper, ContainerService, ExecService) { function ($scope, $transition$, ContainerService, ImageService, EndpointProvider, Notifications, ContainerHelper, ExecService, HttpRequestHelper) {
var socket, term;
$scope.state = { $scope.state = {
loaded: false, loaded: false,
connected: false connected: false
@ -8,8 +10,6 @@ function ($scope, $transition$, Container, Image, EndpointProvider, Notification
$scope.formValues = {}; $scope.formValues = {};
var socket, term;
// Ensure the socket is closed before leaving the view // Ensure the socket is closed before leaving the view
$scope.$on('$stateChangeStart', function (event, next, current) { $scope.$on('$stateChangeStart', function (event, next, current) {
if (socket && socket !== null) { if (socket && socket !== null) {
@ -17,23 +17,6 @@ function ($scope, $transition$, Container, Image, EndpointProvider, Notification
} }
}); });
Container.get({id: $transition$.params().id}, function(d) {
$scope.container = d;
if (d.message) {
Notifications.error('Error', d, 'Unable to retrieve container details');
} else {
Image.get({id: d.Image}, function(imgData) {
$scope.imageOS = imgData.Os;
$scope.formValues.command = imgData.Os === 'windows' ? 'powershell' : 'bash';
$scope.state.loaded = true;
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve image details');
});
}
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve container details');
});
$scope.connect = function() { $scope.connect = function() {
var termWidth = Math.floor(($('#terminal-container').width() - 20) / 8.39); var termWidth = Math.floor(($('#terminal-container').width() - 20) / 8.39);
var termHeight = 30; var termHeight = 30;
@ -54,6 +37,9 @@ function ($scope, $transition$, Container, Image, EndpointProvider, Notification
.then(function success(data) { .then(function success(data) {
execId = data.Id; execId = data.Id;
var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID(); var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID();
if ($transition$.params().nodeName) {
url += '&nodeName=' + $transition$.params().nodeName;
}
if (url.indexOf('https') > -1) { if (url.indexOf('https') > -1) {
url = url.replace('https://', 'wss://'); url = url.replace('https://', 'wss://');
} else { } else {
@ -108,4 +94,25 @@ function ($scope, $transition$, Container, Image, EndpointProvider, Notification
}; };
}; };
} }
function initView() {
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
ContainerService.container($transition$.params().id)
.then(function success(data) {
var container = data;
$scope.container = container;
return ImageService.image(container.Image);
})
.then(function success(data) {
var image = data;
$scope.imageOS = image.Os;
$scope.formValues.command = image.Os === 'windows' ? 'powershell' : 'bash';
$scope.state.loaded = true;
})
.catch(function error(err) {
Notifications.error('Error', err, 'Unable to retrieve container details');
});
}
initView();
}]); }]);

View file

@ -14,6 +14,7 @@
dataset="containers" table-key="containers" dataset="containers" table-key="containers"
order-by="Status" show-text-filter="true" order-by="Status" show-text-filter="true"
show-ownership-column="applicationState.application.authentication" show-ownership-column="applicationState.application.authentication"
show-host-column="applicationState.endpoint.mode.agentProxy"
public-url="state.publicURL" public-url="state.publicURL"
container-name-truncate-size="truncate_size" container-name-truncate-size="truncate_size"
start-action="startAction" start-action="startAction"

View file

@ -1,6 +1,6 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('ContainersController', ['$q', '$scope', '$state', '$filter', '$transition$', 'ContainerService', 'SystemService', 'Notifications', 'ModalService', 'EndpointProvider', .controller('ContainersController', ['$q', '$scope', '$state', '$filter', '$transition$', 'ContainerService', 'SystemService', 'Notifications', 'ModalService', 'EndpointProvider', 'HttpRequestHelper',
function ($q, $scope, $state, $filter, $transition$, ContainerService, SystemService, Notifications, ModalService, EndpointProvider) { function ($q, $scope, $state, $filter, $transition$, ContainerService, SystemService, Notifications, ModalService, EndpointProvider, HttpRequestHelper) {
$scope.state = { $scope.state = {
publicURL: EndpointProvider.endpointPublicURL() publicURL: EndpointProvider.endpointPublicURL()
}; };
@ -70,6 +70,7 @@ angular.module('portainer.docker')
function executeActionOnContainerList(containers, action, successMessage, errorMessage) { function executeActionOnContainerList(containers, action, successMessage, errorMessage) {
var actionCount = containers.length; var actionCount = containers.length;
angular.forEach(containers, function (container) { angular.forEach(containers, function (container) {
HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName);
action(container.Id) action(container.Id)
.then(function success() { .then(function success() {
Notifications.success(successMessage, container.Names[0]); Notifications.success(successMessage, container.Names[0]);
@ -89,6 +90,7 @@ angular.module('portainer.docker')
function removeAction(containers, cleanVolumes) { function removeAction(containers, cleanVolumes) {
var actionCount = containers.length; var actionCount = containers.length;
angular.forEach(containers, function (container) { angular.forEach(containers, function (container) {
HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName);
ContainerService.remove(container, cleanVolumes) ContainerService.remove(container, cleanVolumes)
.then(function success() { .then(function success() {
Notifications.success('Container successfully removed', container.Names[0]); Notifications.success('Container successfully removed', container.Names[0]);

View file

@ -1,6 +1,6 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('CreateContainerController', ['$q', '$scope', '$state', '$timeout', '$transition$', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', 'SystemService', 'SettingsService', .controller('CreateContainerController', ['$q', '$scope', '$state', '$timeout', '$transition$', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', 'SystemService', 'SettingsService', 'HttpRequestHelper',
function ($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, $timeout, $transition$, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService, SystemService, SettingsService, HttpRequestHelper) {
$scope.formValues = { $scope.formValues = {
alwaysPull: true, alwaysPull: true,
@ -15,7 +15,8 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai
AccessControlData: new AccessControlFormData(), AccessControlData: new AccessControlFormData(),
CpuLimit: 0, CpuLimit: 0,
MemoryLimit: 0, MemoryLimit: 0,
MemoryReservation: 0 MemoryReservation: 0,
NodeName: null
}; };
$scope.state = { $scope.state = {
@ -388,12 +389,14 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai
// Mac Address // Mac Address
$scope.formValues.MacAddress = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].MacAddress; $scope.formValues.MacAddress = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].MacAddress;
// ExtraHosts // ExtraHosts
var extraHosts = $scope.config.HostConfig.ExtraHosts; if ($scope.config.HostConfig.ExtraHosts) {
for (var i = 0; i < extraHosts.length; i++) { var extraHosts = $scope.config.HostConfig.ExtraHosts;
var host = extraHosts[i]; for (var i = 0; i < extraHosts.length; i++) {
$scope.formValues.ExtraHosts.push({ 'value': host }); var host = extraHosts[i];
$scope.formValues.ExtraHosts.push({ 'value': host });
}
$scope.config.HostConfig.ExtraHosts = [];
} }
$scope.config.HostConfig.ExtraHosts = [];
} }
function loadFromContainerEnvironmentVariables(d) { function loadFromContainerEnvironmentVariables(d) {
@ -491,6 +494,10 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai
} }
function initView() { function initView() {
var nodeName = $transition$.params().nodeName;
$scope.formValues.NodeName = nodeName;
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
Volume.query({}, function (d) { Volume.query({}, function (d) {
$scope.availableVolumes = d.Volumes; $scope.availableVolumes = d.Volumes;
}, function (e) { }, function (e) {
@ -520,7 +527,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai
Container.query({}, function (d) { Container.query({}, function (d) {
var containers = d; var containers = d;
$scope.runningContainers = containers; $scope.runningContainers = containers;
if ($transition$.params().from !== '') { if ($transition$.params().from) {
loadFromContainerSpec(); loadFromContainerSpec();
} else { } else {
$scope.fromContainer = {}; $scope.fromContainer = {};
@ -587,6 +594,8 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai
$scope.state.actionInProgress = true; $scope.state.actionInProgress = true;
var config = prepareConfiguration(); var config = prepareConfiguration();
var nodeName = $scope.formValues.NodeName;
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
createContainer(config, accessControlData); createContainer(config, accessControlData);
}) })
.catch(function error(err) { .catch(function error(err) {

View file

@ -103,6 +103,16 @@
<!-- !port-mapping-input-list --> <!-- !port-mapping-input-list -->
</div> </div>
<!-- !port-mapping --> <!-- !port-mapping -->
<div ng-if="applicationState.endpoint.mode.agentProxy">
<div class="col-sm-12 form-section-title">
Deployment
</div>
<!-- node-selection -->
<node-selector
model="formValues.NodeName">
</node-selector>
<!-- !node-selection -->
</div>
<!-- access-control --> <!-- access-control -->
<por-access-control-form form-data="formValues.AccessControlData" resource-control="fromContainer.ResourceControl" ng-if="applicationState.application.authentication && fromContainer"></por-access-control-form> <por-access-control-form form-data="formValues.AccessControlData" resource-control="fromContainer.ResourceControl" ng-if="applicationState.application.authentication && fromContainer"></por-access-control-form>
<!-- !access-control --> <!-- !access-control -->

View file

@ -20,12 +20,12 @@
<button class="btn btn-primary btn-sm" ng-click="unpause()" ng-disabled="!container.State.Paused"><i class="fa fa-play space-right" aria-hidden="true"></i>Resume</button> <button class="btn btn-primary btn-sm" ng-click="unpause()" ng-disabled="!container.State.Paused"><i class="fa fa-play space-right" aria-hidden="true"></i>Resume</button>
<button class="btn btn-danger btn-sm" ng-click="confirmRemove()"><i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove</button> <button class="btn btn-danger btn-sm" ng-click="confirmRemove()"><i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove</button>
</div> </div>
<div class="btn-group" role="group" aria-label="..."> <div class="btn-group" role="group" aria-label="..." ng-if="!container.Config.Labels['com.docker.swarm.service.id']">
<button type="button" class="btn btn-danger btn-sm" ng-disabled="state.recreateContainerInProgress" ng-click="recreate()" button-spinner="state.recreateContainerInProgress" ng-if="!container.Config.Labels['com.docker.swarm.service.id']"> <button type="button" class="btn btn-danger btn-sm" ng-disabled="state.recreateContainerInProgress" ng-click="recreate()" button-spinner="state.recreateContainerInProgress">
<span ng-hide="state.recreateContainerInProgress"><i class="fa fa-sync space-right" aria-hidden="true"></i>Recreate</span> <span ng-hide="state.recreateContainerInProgress"><i class="fa fa-sync space-right" aria-hidden="true"></i>Recreate</span>
<span ng-show="state.recreateContainerInProgress">Recreation in progress...</span> <span ng-show="state.recreateContainerInProgress">Recreation in progress...</span>
</button> </button>
<button class="btn btn-primary btn-sm" ng-click="duplicate()" ng-if="!container.Config.Labels['com.docker.swarm.service.id']"><i class="fa fa-copy space-right" aria-hidden="true"></i>Duplicate/Edit</button> <a class="btn btn-primary btn-sm" type="button" ui-sref="docker.containers.new({ from: container.Id, nodeName: nodeName })"><i class="fa fa-copy space-right" aria-hidden="true"></i>Duplicate/Edit</a>
</div> </div>
</rd-widget-body> </rd-widget-body>
</rd-widget> </rd-widget>
@ -84,10 +84,10 @@
<tr> <tr>
<td colspan="2"> <td colspan="2">
<div class="btn-group" role="group" aria-label="..."> <div class="btn-group" role="group" aria-label="...">
<a class="btn" type="button" ui-sref="docker.containers.container.stats({id: container.Id})"><i class="fa fa-chart-area space-right" aria-hidden="true"></i>Stats</a> <a class="btn" type="button" ui-sref="docker.containers.container.stats({ id: container.Id })"><i class="fa fa-chart-area space-right" aria-hidden="true"></i>Stats</a>
<a class="btn" type="button" ui-sref="docker.containers.container.logs({id: container.Id})"><i class="fa fa-file-alt space-right" aria-hidden="true"></i>Logs</a> <a class="btn" type="button" ui-sref="docker.containers.container.logs({ id: container.Id })"><i class="fa fa-file-alt space-right" aria-hidden="true"></i>Logs</a>
<a class="btn" type="button" ui-sref="docker.containers.container.console({id: container.Id})"><i class="fa fa-terminal space-right" aria-hidden="true"></i>Console</a> <a class="btn" type="button" ui-sref="docker.containers.container.console({ id: container.Id })"><i class="fa fa-terminal space-right" aria-hidden="true"></i>Console</a>
<a class="btn" type="button" ui-sref="docker.containers.container.inspect({id: container.Id})"><i class="fa fa-info-circle space-right" aria-hidden="true"></i>Inspect</a> <a class="btn" type="button" ui-sref="docker.containers.container.inspect({ id: container.Id })"><i class="fa fa-info-circle space-right" aria-hidden="true"></i>Inspect</a>
</div> </div>
</td> </td>
</tr> </tr>
@ -184,7 +184,7 @@
<tbody> <tbody>
<tr> <tr>
<td>Image</td> <td>Image</td>
<td><a ui-sref="docker.images.image({id: container.Image})">{{ container.Image }}</a></td> <td><a ui-sref="docker.images.image({ id: container.Image, nodeName: nodeName })">{{ container.Image }}</a></td>
</tr> </tr>
<tr ng-if="portBindings.length > 0"> <tr ng-if="portBindings.length > 0">
<td>Port configuration</td> <td>Port configuration</td>
@ -259,7 +259,7 @@
<tbody> <tbody>
<tr ng-repeat="vol in container.Mounts"> <tr ng-repeat="vol in container.Mounts">
<td ng-if="vol.Type === 'bind'">{{ vol.Source }}</td> <td ng-if="vol.Type === 'bind'">{{ vol.Source }}</td>
<td ng-if="vol.Type === 'volume'"><a ui-sref="docker.volumes.volume({id: vol.Name})">{{ vol.Name }}</a></td> <td ng-if="vol.Type === 'volume'"><a ui-sref="docker.volumes.volume({ id: vol.Name, nodeName: nodeName })">{{ vol.Name }}</a></td>
<td>{{ vol.Destination }}</td> <td>{{ vol.Destination }}</td>
</tr> </tr>
</tbody> </tbody>
@ -280,6 +280,7 @@
join-network-action-in-progress="state.joinNetworkInProgress" join-network-action-in-progress="state.joinNetworkInProgress"
leave-network-action="containerLeaveNetwork" leave-network-action="containerLeaveNetwork"
leave-network-action-in-progress="state.leaveNetworkInProgress" leave-network-action-in-progress="state.leaveNetworkInProgress"
node-name="nodeName"
></container-networks-datatable> ></container-networks-datatable>
</div> </div>
</div> </div>

View file

@ -1,6 +1,6 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('ContainerController', ['$q', '$scope', '$state','$transition$', '$filter', 'Container', 'Commit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService', .controller('ContainerController', ['$q', '$scope', '$state','$transition$', '$filter', 'Commit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'NetworkService', 'Notifications', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService', 'HttpRequestHelper',
function ($q, $scope, $state, $transition$, $filter, Container, Commit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, ModalService, ResourceControlService, RegistryService, ImageService) { function ($q, $scope, $state, $transition$, $filter, Commit, ContainerHelper, ContainerService, ImageHelper, NetworkService, Notifications, ModalService, ResourceControlService, RegistryService, ImageService, HttpRequestHelper) {
$scope.activityTime = 0; $scope.activityTime = 0;
$scope.portBindings = []; $scope.portBindings = [];
@ -16,8 +16,13 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe
}; };
var update = function () { var update = function () {
Container.get({id: $transition$.params().id}, function (d) { var nodeName = $transition$.params().nodeName;
var container = new ContainerDetailsViewModel(d); HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
$scope.nodeName = nodeName;
ContainerService.container($transition$.params().id)
.then(function success(data) {
var container = data;
$scope.container = container; $scope.container = container;
$scope.container.edit = false; $scope.container.edit = false;
$scope.container.newContainerName = $filter('trimcontainername')(container.Name); $scope.container.newContainerName = $filter('trimcontainername')(container.Name);
@ -41,38 +46,102 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe
} }
}); });
} }
}, function (e) { })
Notifications.error('Failure', e, 'Unable to retrieve container info'); .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve container info');
}); });
}; };
$scope.start = function () { function executeContainerAction(id, action, successMessage, errorMessage) {
Container.start({id: $scope.container.Id}, {}, function (d) { action(id)
.then(function success(data) {
Notifications.success(successMessage, id);
update(); update();
Notifications.success('Container started', $transition$.params().id); })
}, function (e) { .catch(function error(err) {
update(); Notifications.error('Failure', err, errorMessage);
Notifications.error('Failure', e, 'Unable to start container');
}); });
}
$scope.start = function () {
var successMessage = 'Container successfully started';
var errorMessage = 'Unable to start container';
executeContainerAction($transition$.params().id, ContainerService.startContainer, successMessage, errorMessage);
}; };
$scope.stop = function () { $scope.stop = function () {
Container.stop({id: $transition$.params().id}, function (d) { var successMessage = 'Container successfully stopped';
update(); var errorMessage = 'Unable to stop container';
Notifications.success('Container stopped', $transition$.params().id); executeContainerAction($transition$.params().id, ContainerService.stopContainer, successMessage, errorMessage);
}, function (e) {
update();
Notifications.error('Failure', e, 'Unable to stop container');
});
}; };
$scope.kill = function () { $scope.kill = function () {
Container.kill({id: $transition$.params().id}, function (d) { var successMessage = 'Container successfully killed';
update(); var errorMessage = 'Unable to kill container';
Notifications.success('Container killed', $transition$.params().id); executeContainerAction($transition$.params().id, ContainerService.killContainer, successMessage, errorMessage);
}, function (e) { };
update();
Notifications.error('Failure', e, 'Unable to kill container'); $scope.pause = function() {
var successMessage = 'Container successfully paused';
var errorMessage = 'Unable to pause container';
executeContainerAction($transition$.params().id, ContainerService.pauseContainer, successMessage, errorMessage);
};
$scope.unpause = function() {
var successMessage = 'Container successfully resumed';
var errorMessage = 'Unable to resume container';
executeContainerAction($transition$.params().id, ContainerService.resumeContainer, successMessage, errorMessage);
};
$scope.restart = function () {
var successMessage = 'Container successfully restarted';
var errorMessage = 'Unable to restart container';
executeContainerAction($transition$.params().id, ContainerService.restartContainer, successMessage, errorMessage);
};
$scope.renameContainer = function () {
var container = $scope.container;
ContainerService.renameContainer($transition$.params().id, container.newContainerName)
.then(function success(data) {
container.Name = container.newContainerName;
Notifications.success('Container successfully renamed', container.Name);
})
.catch(function error(err) {
container.newContainerName = container.Name;
Notifications.error('Failure', err, 'Unable to rename container');
})
.finally(function final() {
$scope.container.edit = false;
});
};
$scope.containerLeaveNetwork = function containerLeaveNetwork(container, networkId) {
$scope.state.leaveNetworkInProgress = true;
NetworkService.disconnectContainer(networkId, container.Id, false)
.then(function success(data) {
Notifications.success('Container left network', container.Id);
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to disconnect container from network');
})
.finally(function final() {
$scope.state.leaveNetworkInProgress = false;
});
};
$scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) {
$scope.state.joinNetworkInProgress = true;
NetworkService.connectContainer(networkId, container.Id)
.then(function success(data) {
Notifications.success('Container joined network', container.Id);
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect container to network');
})
.finally(function final() {
$scope.state.joinNetworkInProgress = false;
}); });
}; };
@ -89,25 +158,6 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe
}); });
}; };
$scope.pause = function () {
Container.pause({id: $transition$.params().id}, function (d) {
update();
Notifications.success('Container paused', $transition$.params().id);
}, function (e) {
update();
Notifications.error('Failure', e, 'Unable to pause container');
});
};
$scope.unpause = function () {
Container.unpause({id: $transition$.params().id}, function (d) {
update();
Notifications.success('Container unpaused', $transition$.params().id);
}, function (e) {
update();
Notifications.error('Failure', e, 'Unable to unpause container');
});
};
$scope.confirmRemove = function () { $scope.confirmRemove = function () {
var title = 'You are about to remove a container.'; var title = 'You are about to remove a container.';
@ -122,12 +172,12 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe
if (result[0]) { if (result[0]) {
cleanAssociatedVolumes = true; cleanAssociatedVolumes = true;
} }
$scope.remove(cleanAssociatedVolumes); removeContainer(cleanAssociatedVolumes);
} }
); );
}; };
$scope.remove = function(cleanAssociatedVolumes) { function removeContainer(cleanAssociatedVolumes) {
ContainerService.remove($scope.container, cleanAssociatedVolumes) ContainerService.remove($scope.container, cleanAssociatedVolumes)
.then(function success() { .then(function success() {
Notifications.success('Container successfully removed'); Notifications.success('Container successfully removed');
@ -136,71 +186,7 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe
.catch(function error(err) { .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove container'); Notifications.error('Failure', err, 'Unable to remove container');
}); });
}; }
$scope.restart = function () {
Container.restart({id: $transition$.params().id}, function (d) {
update();
Notifications.success('Container restarted', $transition$.params().id);
}, function (e) {
update();
Notifications.error('Failure', e, 'Unable to restart container');
});
};
$scope.renameContainer = function () {
var container = $scope.container;
Container.rename({id: $transition$.params().id, 'name': container.newContainerName}, function (d) {
if (d.message) {
container.newContainerName = container.Name;
Notifications.error('Unable to rename container', {}, d.message);
} else {
container.Name = container.newContainerName;
Notifications.success('Container successfully renamed', container.Name);
}
}, function (e) {
Notifications.error('Failure', e, 'Unable to rename container');
});
$scope.container.edit = false;
};
$scope.containerLeaveNetwork = function containerLeaveNetwork(container, networkId) {
$scope.state.leaveNetworkInProgress = true;
Network.disconnect({id: networkId}, { Container: $transition$.params().id, Force: false }, function (d) {
if (container.message) {
Notifications.error('Error', d, 'Unable to disconnect container from network');
} else {
Notifications.success('Container left network', $transition$.params().id);
$state.go('docker.containers.container', {id: $transition$.params().id}, {reload: true});
}
$scope.state.leaveNetworkInProgress = false;
}, function (e) {
Notifications.error('Failure', e, 'Unable to disconnect container from network');
$scope.state.leaveNetworkInProgress = false;
});
};
$scope.duplicate = function() {
$state.go('docker.containers.new', {from: $transition$.params().id}, {reload: true});
};
$scope.confirmRemove = function () {
var title = 'You are about to remove a container.';
if ($scope.container.State.Running) {
title = 'You are about to remove a running container.';
}
ModalService.confirmContainerDeletion(
title,
function (result) {
if(!result) { return; }
var cleanAssociatedVolumes = false;
if (result[0]) {
cleanAssociatedVolumes = true;
}
$scope.remove(cleanAssociatedVolumes);
}
);
};
function recreateContainer(pullImage) { function recreateContainer(pullImage) {
var container = $scope.container; var container = $scope.container;
@ -253,22 +239,6 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe
}); });
}; };
$scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) {
$scope.state.joinNetworkInProgress = true;
Network.connect({id: networkId}, { Container: $transition$.params().id }, function (d) {
if (container.message) {
Notifications.error('Error', d, 'Unable to connect container to network');
} else {
Notifications.success('Container joined network', $transition$.params().id);
$state.go('docker.containers.container', {id: $transition$.params().id}, {reload: true});
}
$scope.state.joinNetworkInProgress = false;
}, function (e) {
Notifications.error('Failure', e, 'Unable to connect container to network');
$scope.state.joinNetworkInProgress = false;
});
};
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;
NetworkService.networks( NetworkService.networks(

View file

@ -1,6 +1,6 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('ContainerInspectController', ['$scope', '$transition$', 'Notifications', 'ContainerService', .controller('ContainerInspectController', ['$scope', '$transition$', 'Notifications', 'ContainerService', 'HttpRequestHelper',
function ($scope, $transition$, Notifications, ContainerService) { function ($scope, $transition$, Notifications, ContainerService, HttpRequestHelper) {
$scope.state = { $scope.state = {
DisplayTextView: false DisplayTextView: false
@ -8,6 +8,7 @@ function ($scope, $transition$, Notifications, ContainerService) {
$scope.containerInfo = {}; $scope.containerInfo = {};
function initView() { function initView() {
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
ContainerService.inspect($transition$.params().id) ContainerService.inspect($transition$.params().id)
.then(function success(d) { .then(function success(d) {
$scope.containerInfo = d; $scope.containerInfo = d;

View file

@ -1,6 +1,6 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('ContainerLogsController', ['$scope', '$transition$', '$interval', 'ContainerService', 'Notifications', .controller('ContainerLogsController', ['$scope', '$transition$', '$interval', 'ContainerService', 'Notifications', 'HttpRequestHelper',
function ($scope, $transition$, $interval, ContainerService, Notifications) { function ($scope, $transition$, $interval, ContainerService, Notifications, HttpRequestHelper) {
$scope.state = { $scope.state = {
refreshRate: 3, refreshRate: 3,
lineCount: 2000, lineCount: 2000,
@ -58,6 +58,7 @@ function ($scope, $transition$, $interval, ContainerService, Notifications) {
} }
function initView() { function initView() {
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
ContainerService.container($transition$.params().id) ContainerService.container($transition$.params().id)
.then(function success(data) { .then(function success(data) {
var container = data; var container = data;

View file

@ -1,6 +1,6 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('ContainerStatsController', ['$q', '$scope', '$transition$', '$document', '$interval', 'ContainerService', 'ChartService', 'Notifications', .controller('ContainerStatsController', ['$q', '$scope', '$transition$', '$document', '$interval', 'ContainerService', 'ChartService', 'Notifications', 'HttpRequestHelper',
function ($q, $scope, $transition$, $document, $interval, ContainerService, ChartService, Notifications) { function ($q, $scope, $transition$, $document, $interval, ContainerService, ChartService, Notifications, HttpRequestHelper) {
$scope.state = { $scope.state = {
refreshRate: '5', refreshRate: '5',
@ -126,6 +126,7 @@ function ($q, $scope, $transition$, $document, $interval, ContainerService, Char
} }
function initView() { function initView() {
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
ContainerService.container($transition$.params().id) ContainerService.container($transition$.params().id)
.then(function success(data) { .then(function success(data) {
$scope.container = data; $scope.container = data;

View file

@ -3,7 +3,13 @@
<rd-header-content>Dashboard</rd-header-content> <rd-header-content>Dashboard</rd-header-content>
</rd-header> </rd-header>
<div class="row"> <div class="row" ng-if="applicationState.endpoint.mode.agentProxy">
<div class="col-sm-12">
<dashboard-cluster-agent-info></dashboard-cluster-agent-info>
</div>
</div>
<div class="row" ng-if="!applicationState.endpoint.mode.agentProxy">
<div class="col-sm-12"> <div class="col-sm-12">
<rd-widget> <rd-widget>
<rd-widget-header icon="fa-tachometer-alt" title="Node info"></rd-widget-header> <rd-widget-header icon="fa-tachometer-alt" title="Node info"></rd-widget-header>

View file

@ -1,6 +1,6 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('BuildImageController', ['$scope', '$state', 'BuildService', 'Notifications', .controller('BuildImageController', ['$scope', '$state', 'BuildService', 'Notifications', 'HttpRequestHelper',
function ($scope, $state, BuildService, Notifications) { function ($scope, $state, BuildService, Notifications, HttpRequestHelper) {
$scope.state = { $scope.state = {
BuildType: 'editor', BuildType: 'editor',
@ -13,7 +13,8 @@ function ($scope, $state, BuildService, Notifications) {
UploadFile: null, UploadFile: null,
DockerFileContent: '', DockerFileContent: '',
URL: '', URL: '',
Path: 'Dockerfile' Path: 'Dockerfile',
NodeName: null
}; };
$scope.addImageName = function() { $scope.addImageName = function() {
@ -56,6 +57,9 @@ function ($scope, $state, BuildService, Notifications) {
return x.Name; return x.Name;
}); });
var nodeName = $scope.formValues.NodeName;
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
buildImageBasedOnBuildType(buildType, imageNames) buildImageBasedOnBuildType(buildType, imageNames)
.then(function success(data) { .then(function success(data) {
$scope.buildLogs = data.buildLogs; $scope.buildLogs = data.buildLogs;

View file

@ -194,6 +194,16 @@
</div> </div>
</div> </div>
<!-- !url --> <!-- !url -->
<div ng-if="applicationState.endpoint.mode.agentProxy">
<div class="col-sm-12 form-section-title">
Deployment
</div>
<!-- node-selection -->
<node-selector
model="formValues.NodeName">
</node-selector>
<!-- !node-selection -->
</div>
<!-- actions --> <!-- actions -->
<div class="col-sm-12 form-section-title"> <div class="col-sm-12 form-section-title">
Actions Actions

View file

@ -1,6 +1,6 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('ImageController', ['$q', '$scope', '$transition$', '$state', '$timeout', 'ImageService', 'RegistryService', 'Notifications', .controller('ImageController', ['$q', '$scope', '$transition$', '$state', '$timeout', 'ImageService', 'RegistryService', 'Notifications', 'HttpRequestHelper',
function ($q, $scope, $transition$, $state, $timeout, ImageService, RegistryService, Notifications) { function ($q, $scope, $transition$, $state, $timeout, ImageService, RegistryService, Notifications, HttpRequestHelper) {
$scope.formValues = { $scope.formValues = {
Image: '', Image: '',
Registry: '' Registry: ''
@ -98,6 +98,7 @@ function ($q, $scope, $transition$, $state, $timeout, ImageService, RegistryServ
}; };
function initView() { function initView() {
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
var endpointProvider = $scope.applicationState.endpoint.mode.provider; var endpointProvider = $scope.applicationState.endpoint.mode.provider;
$q.all({ $q.all({
image: ImageService.image($transition$.params().id), image: ImageService.image($transition$.params().id),

View file

@ -26,6 +26,16 @@
</div> </div>
</div> </div>
<!-- !tag-note --> <!-- !tag-note -->
<div ng-if="applicationState.endpoint.mode.agentProxy">
<div class="col-sm-12 form-section-title">
Deployment
</div>
<!-- node-selection -->
<node-selector
model="formValues.NodeName">
</node-selector>
<!-- !node-selection -->
</div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !formValues.Image" ng-click="pullImage()" button-spinner="state.actionInProgress"> <button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !formValues.Image" ng-click="pullImage()" button-spinner="state.actionInProgress">
@ -46,6 +56,7 @@
title="Images" title-icon="fa-clone" title="Images" title-icon="fa-clone"
dataset="images" table-key="images" dataset="images" table-key="images"
order-by="RepoTags" show-text-filter="true" order-by="RepoTags" show-text-filter="true"
show-host-column="applicationState.endpoint.mode.agentProxy"
remove-action="removeAction" remove-action="removeAction"
force-remove-action="confirmRemovalAction" force-remove-action="confirmRemovalAction"
></images-datatable> ></images-datatable>

View file

@ -1,19 +1,23 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('ImagesController', ['$scope', '$state', 'ImageService', 'Notifications', 'ModalService', .controller('ImagesController', ['$scope', '$state', 'ImageService', 'Notifications', 'ModalService', 'HttpRequestHelper',
function ($scope, $state, ImageService, Notifications, ModalService) { function ($scope, $state, ImageService, Notifications, ModalService, HttpRequestHelper) {
$scope.state = { $scope.state = {
actionInProgress: false actionInProgress: false
}; };
$scope.formValues = { $scope.formValues = {
Image: '', Image: '',
Registry: '' Registry: '',
NodeName: null
}; };
$scope.pullImage = function() { $scope.pullImage = function() {
var image = $scope.formValues.Image; var image = $scope.formValues.Image;
var registry = $scope.formValues.Registry; var registry = $scope.formValues.Registry;
var nodeName = $scope.formValues.NodeName;
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
$scope.state.actionInProgress = true; $scope.state.actionInProgress = true;
ImageService.pullImage(image, registry, false) ImageService.pullImage(image, registry, false)
.then(function success(data) { .then(function success(data) {
@ -38,6 +42,7 @@ function ($scope, $state, ImageService, Notifications, ModalService) {
$scope.removeAction = function (selectedItems, force) { $scope.removeAction = function (selectedItems, force) {
var actionCount = selectedItems.length; var actionCount = selectedItems.length;
angular.forEach(selectedItems, function (image) { angular.forEach(selectedItems, function (image) {
HttpRequestHelper.setPortainerAgentTargetHeader(image.NodeName);
ImageService.deleteImage(image.Id, force) ImageService.deleteImage(image.Id, force)
.then(function success() { .then(function success() {
Notifications.success('Image successfully removed', image.Id); Notifications.success('Image successfully removed', image.Id);

View file

@ -1,13 +1,14 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('CreateNetworkController', ['$q', '$scope', '$state', 'PluginService', 'Notifications', 'NetworkService', 'LabelHelper', 'Authentication', 'ResourceControlService', 'FormValidator', .controller('CreateNetworkController', ['$q', '$scope', '$state', 'PluginService', 'Notifications', 'NetworkService', 'LabelHelper', 'Authentication', 'ResourceControlService', 'FormValidator', 'HttpRequestHelper',
function ($q, $scope, $state, PluginService, Notifications, NetworkService, LabelHelper, Authentication, ResourceControlService, FormValidator) { function ($q, $scope, $state, PluginService, Notifications, NetworkService, LabelHelper, Authentication, ResourceControlService, FormValidator, HttpRequestHelper) {
$scope.formValues = { $scope.formValues = {
DriverOptions: [], DriverOptions: [],
Subnet: '', Subnet: '',
Gateway: '', Gateway: '',
Labels: [], Labels: [],
AccessControlData: new AccessControlFormData() AccessControlData: new AccessControlFormData(),
NodeName: null
}; };
$scope.state = { $scope.state = {
@ -99,6 +100,9 @@ function ($q, $scope, $state, PluginService, Notifications, NetworkService, Labe
return; return;
} }
var nodeName = $scope.formValues.NodeName;
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
$scope.state.actionInProgress = true; $scope.state.actionInProgress = true;
NetworkService.create(networkConfiguration) NetworkService.create(networkConfiguration)
.then(function success(data) { .then(function success(data) {

View file

@ -119,6 +119,16 @@
</div> </div>
</div> </div>
<!-- !internal --> <!-- !internal -->
<div ng-if="applicationState.endpoint.mode.agentProxy && config.Driver !== 'overlay'">
<div class="col-sm-12 form-section-title">
Deployment
</div>
<!-- node-selection -->
<node-selector
model="formValues.NodeName">
</node-selector>
<!-- !node-selection -->
</div>
<!-- access-control --> <!-- access-control -->
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form> <por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<!-- !access-control --> <!-- !access-control -->

View file

@ -89,12 +89,12 @@
</thead> </thead>
<tbody> <tbody>
<tr ng-repeat="container in containersInNetwork"> <tr ng-repeat="container in containersInNetwork">
<td><a ui-sref="docker.containers.container({id: container.Id})">{{ container.Name }}</a></td> <td><a ui-sref="docker.containers.container({ id: container.Id, nodeName: nodeName })">{{ container.Name }}</a></td>
<td>{{ container.IPv4Address || '-' }}</td> <td>{{ container.IPv4Address || '-' }}</td>
<td>{{ container.IPv6Address || '-' }}</td> <td>{{ container.IPv6Address || '-' }}</td>
<td>{{ container.MacAddress || '-' }}</td> <td>{{ container.MacAddress || '-' }}</td>
<td> <td>
<button type="button" class="btn btn-xs btn-danger" ng-click="containerLeaveNetwork(network, container.Id)"><i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Leave Network</button> <button type="button" class="btn btn-xs btn-danger" ng-click="containerLeaveNetwork(network, container)"><i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Leave Network</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>

View file

@ -1,30 +1,27 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('NetworkController', ['$scope', '$state', '$transition$', '$filter', 'Network', 'NetworkService', 'Container', 'ContainerHelper', 'Notifications', .controller('NetworkController', ['$scope', '$state', '$transition$', '$filter', 'NetworkService', 'Container', 'ContainerHelper', 'Notifications', 'HttpRequestHelper',
function ($scope, $state, $transition$, $filter, Network, NetworkService, Container, ContainerHelper, Notifications) { function ($scope, $state, $transition$, $filter, NetworkService, Container, ContainerHelper, Notifications, HttpRequestHelper) {
$scope.removeNetwork = function removeNetwork(networkId) { $scope.removeNetwork = function removeNetwork(networkId) {
Network.remove({id: $transition$.params().id}, function (d) { NetworkService.remove($transition$.params().id, $transition$.params().id)
if (d.message) { .then(function success(data) {
Notifications.error('Error', d, 'Unable to remove network'); Notifications.success('Network removed', $transition$.params().id);
} else { $state.go('docker.networks', {});
Notifications.success('Network removed', $transition$.params().id); })
$state.go('docker.networks', {}); .catch(function error(err) {
} Notifications.error('Failure', err, 'Unable to remove network');
}, function (e) {
Notifications.error('Failure', e, 'Unable to remove network');
}); });
}; };
$scope.containerLeaveNetwork = function containerLeaveNetwork(network, containerId) { $scope.containerLeaveNetwork = function containerLeaveNetwork(network, container) {
Network.disconnect({id: $transition$.params().id}, { Container: containerId, Force: false }, function (d) { HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName);
if (d.message) { NetworkService.disconnectContainer($transition$.params().id, container.Id, false)
Notifications.error('Error', d, 'Unable to disconnect container from network'); .then(function success(data) {
} else { Notifications.success('Container left network', $transition$.params().id);
Notifications.success('Container left network', $transition$.params().id); $state.go('docker.networks.network', { id: network.Id }, { reload: true });
$state.go('docker.networks.network', {id: network.Id}, {reload: true}); })
} .catch(function error(err) {
}, function (e) { Notifications.error('Failure', err, 'Unable to disconnect container from network');
Notifications.error('Failure', e, 'Unable to disconnect container from network');
}); });
}; };
@ -71,6 +68,9 @@ function ($scope, $state, $transition$, $filter, Network, NetworkService, Contai
} }
function initView() { function initView() {
var nodeName = $transition$.params().nodeName;
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
$scope.nodeName = nodeName;
NetworkService.network($transition$.params().id) NetworkService.network($transition$.params().id)
.then(function success(data) { .then(function success(data) {
$scope.network = data; $scope.network = data;

View file

@ -15,6 +15,7 @@
order-by="Name" show-text-filter="true" order-by="Name" show-text-filter="true"
remove-action="removeAction" remove-action="removeAction"
show-ownership-column="applicationState.application.authentication" show-ownership-column="applicationState.application.authentication"
show-host-column="applicationState.endpoint.mode.agentProxy"
></networks-datatable> ></networks-datatable>
</div> </div>
</div> </div>

View file

@ -1,10 +1,11 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('NetworksController', ['$scope', '$state', 'NetworkService', 'Notifications', .controller('NetworksController', ['$scope', '$state', 'NetworkService', 'Notifications', 'HttpRequestHelper',
function ($scope, $state, NetworkService, Notifications) { function ($scope, $state, NetworkService, Notifications, HttpRequestHelper) {
$scope.removeAction = function (selectedItems) { $scope.removeAction = function (selectedItems) {
var actionCount = selectedItems.length; var actionCount = selectedItems.length;
angular.forEach(selectedItems, function (network) { angular.forEach(selectedItems, function (network) {
HttpRequestHelper.setPortainerAgentTargetHeader(network.NodeName);
NetworkService.remove(network.Id) NetworkService.remove(network.Id)
.then(function success() { .then(function success() {
Notifications.success('Network successfully removed', network.Name); Notifications.success('Network successfully removed', network.Name);

View file

@ -7,5 +7,6 @@
show-text-filter="true" show-text-filter="true"
show-slot-column="service.Mode !== 'global'" show-slot-column="service.Mode !== 'global'"
show-logs-button="applicationState.endpoint.apiVersion >= 1.30" show-logs-button="applicationState.endpoint.apiVersion >= 1.30"
agent-powered="applicationState.endpoint.mode.agentProxy"
></tasks-datatable> ></tasks-datatable>
</div> </div>

View file

@ -1,6 +1,6 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'ConfigService', 'ConfigHelper', 'SecretService', 'ImageService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'ModalService', 'PluginService', 'Authentication', 'SettingsService', 'VolumeService', .controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'ConfigService', 'ConfigHelper', 'SecretService', 'ImageService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'ContainerService', 'TaskHelper', 'Notifications', 'ModalService', 'PluginService', 'Authentication', 'SettingsService', 'VolumeService',
function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, ConfigService, ConfigHelper, SecretService, ImageService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, ModalService, PluginService, Authentication, SettingsService, VolumeService) { function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, ConfigService, ConfigHelper, SecretService, ImageService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, ContainerService, TaskHelper, Notifications, ModalService, PluginService, Authentication, SettingsService, VolumeService) {
$scope.state = { $scope.state = {
updateInProgress: false, updateInProgress: false,
@ -407,6 +407,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
function initView() { function initView() {
var apiVersion = $scope.applicationState.endpoint.apiVersion; var apiVersion = $scope.applicationState.endpoint.apiVersion;
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
ServiceService.service($transition$.params().id) ServiceService.service($transition$.params().id)
.then(function success(data) { .then(function success(data) {
@ -425,6 +426,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
return $q.all({ return $q.all({
volumes: VolumeService.volumes(), volumes: VolumeService.volumes(),
tasks: TaskService.tasks({ service: [service.Name] }), tasks: TaskService.tasks({ service: [service.Name] }),
containers: agentProxy ? ContainerService.containers() : [],
nodes: NodeService.nodes(), nodes: NodeService.nodes(),
secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], secrets: apiVersion >= 1.25 ? SecretService.secrets() : [],
configs: apiVersion >= 1.30 ? ConfigService.configs() : [], configs: apiVersion >= 1.30 ? ConfigService.configs() : [],
@ -434,7 +436,6 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
}); });
}) })
.then(function success(data) { .then(function success(data) {
$scope.tasks = data.tasks;
$scope.nodes = data.nodes; $scope.nodes = data.nodes;
$scope.configs = data.configs; $scope.configs = data.configs;
$scope.secrets = data.secrets; $scope.secrets = data.secrets;
@ -445,6 +446,19 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
var userDetails = Authentication.getUserDetails(); var userDetails = Authentication.getUserDetails();
$scope.isAdmin = userDetails.role === 1; $scope.isAdmin = userDetails.role === 1;
var tasks = data.tasks;
if (agentProxy) {
var containers = data.containers;
for (var i = 0; i < tasks.length; i++) {
var task = tasks[i];
TaskHelper.associateContainerToTask(task, containers);
}
}
$scope.tasks = data.tasks;
// Set max cpu value // Set max cpu value
var maxCpus = 0; var maxCpus = 0;
for (var n in data.nodes) { for (var n in data.nodes) {

View file

@ -42,6 +42,7 @@
show-text-filter="true" show-text-filter="true"
show-slot-column="true" show-slot-column="true"
show-logs-button="applicationState.endpoint.apiVersion >= 1.30" show-logs-button="applicationState.endpoint.apiVersion >= 1.30"
agent-powered="applicationState.endpoint.mode.agentProxy"
></tasks-datatable> ></tasks-datatable>
</div> </div>
</div> </div>

View file

@ -1,6 +1,6 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('StackController', ['$q', '$scope', '$state', '$transition$', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'Notifications', 'FormHelper', 'EndpointProvider', .controller('StackController', ['$q', '$scope', '$state', '$transition$', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ContainerService', 'ServiceHelper', 'TaskHelper', 'Notifications', 'FormHelper', 'EndpointProvider',
function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceService, TaskService, ServiceHelper, Notifications, FormHelper, EndpointProvider) { function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceService, TaskService, ContainerService, ServiceHelper, TaskHelper, Notifications, FormHelper, EndpointProvider) {
$scope.state = { $scope.state = {
actionInProgress: false, actionInProgress: false,
@ -41,6 +41,7 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe
function initView() { function initView() {
var stackId = $transition$.params().id; var stackId = $transition$.params().id;
var apiVersion = $scope.applicationState.endpoint.apiVersion; var apiVersion = $scope.applicationState.endpoint.apiVersion;
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
StackService.stack(stackId) StackService.stack(stackId)
.then(function success(data) { .then(function success(data) {
@ -55,24 +56,31 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe
stackFile: StackService.getStackFile(stackId), stackFile: StackService.getStackFile(stackId),
services: ServiceService.services(serviceFilters), services: ServiceService.services(serviceFilters),
tasks: TaskService.tasks(serviceFilters), tasks: TaskService.tasks(serviceFilters),
containers: agentProxy ? ContainerService.containers() : [],
nodes: NodeService.nodes() nodes: NodeService.nodes()
}); });
}) })
.then(function success(data) { .then(function success(data) {
$scope.stackFileContent = data.stackFile; $scope.stackFileContent = data.stackFile;
$scope.nodes = data.nodes; $scope.nodes = data.nodes;
var services = data.services; var services = data.services;
var tasks = data.tasks; var tasks = data.tasks;
$scope.tasks = tasks;
for (var i = 0; i < services.length; i++) { for (var i = 0; i < services.length; i++) {
var service = services[i]; var service = services[i];
ServiceHelper.associateTasksToService(service, tasks); ServiceHelper.associateTasksToService(service, tasks);
} }
if (agentProxy) {
var containers = data.containers;
for (var j = 0; j < tasks.length; j++) {
var task = tasks[j];
TaskHelper.associateContainerToTask(task, containers);
}
}
$scope.tasks = tasks;
$scope.services = services; $scope.services = services;
}) })
.catch(function error(err) { .catch(function error(err) {

View file

@ -1,11 +1,12 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('CreateVolumeController', ['$q', '$scope', '$state', 'VolumeService', 'PluginService', 'ResourceControlService', 'Authentication', 'Notifications', 'FormValidator', .controller('CreateVolumeController', ['$q', '$scope', '$state', 'VolumeService', 'PluginService', 'ResourceControlService', 'Authentication', 'Notifications', 'FormValidator', 'HttpRequestHelper',
function ($q, $scope, $state, VolumeService, PluginService, ResourceControlService, Authentication, Notifications, FormValidator) { function ($q, $scope, $state, VolumeService, PluginService, ResourceControlService, Authentication, Notifications, FormValidator, HttpRequestHelper) {
$scope.formValues = { $scope.formValues = {
Driver: 'local', Driver: 'local',
DriverOptions: [], DriverOptions: [],
AccessControlData: new AccessControlFormData() AccessControlData: new AccessControlFormData(),
NodeName: null
}; };
$scope.state = { $scope.state = {
@ -55,6 +56,9 @@ function ($q, $scope, $state, VolumeService, PluginService, ResourceControlServi
return; return;
} }
var nodeName = $scope.formValues.NodeName;
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
$scope.state.actionInProgress = true; $scope.state.actionInProgress = true;
VolumeService.createVolume(volumeConfiguration) VolumeService.createVolume(volumeConfiguration)
.then(function success(data) { .then(function success(data) {
@ -76,7 +80,7 @@ function ($q, $scope, $state, VolumeService, PluginService, ResourceControlServi
function initView() { function initView() {
var apiVersion = $scope.applicationState.endpoint.apiVersion; var apiVersion = $scope.applicationState.endpoint.apiVersion;
var endpointProvider = $scope.applicationState.endpoint.provider; var endpointProvider = $scope.applicationState.endpoint.mode.provider;
PluginService.volumePlugins(apiVersion < 1.25 || endpointProvider === 'VMWARE_VIC') PluginService.volumePlugins(apiVersion < 1.25 || endpointProvider === 'VMWARE_VIC')
.then(function success(data) { .then(function success(data) {

View file

@ -6,7 +6,7 @@
</rd-header> </rd-header>
<div class="row"> <div class="row">
<div class="col-lg-12 col-md-12 col-xs-12"> <div class="col-sm-12">
<rd-widget> <rd-widget>
<rd-widget-body> <rd-widget-body>
<form class="form-horizontal"> <form class="form-horizontal">
@ -70,6 +70,16 @@
<storidge-profile-selector storidge-profile="formValues.StoridgeProfile"></storidge-profile-selector> <storidge-profile-selector storidge-profile="formValues.StoridgeProfile"></storidge-profile-selector>
</div> </div>
<!-- storidge --> <!-- storidge -->
<div ng-if="applicationState.endpoint.mode.agentProxy && formValues.Driver === 'local'">
<div class="col-sm-12 form-section-title">
Deployment
</div>
<!-- node-selection -->
<node-selector
model="formValues.NodeName">
</node-selector>
<!-- !node-selection -->
</div>
<!-- access-control --> <!-- access-control -->
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form> <por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<!-- !access-control --> <!-- !access-control -->

View file

@ -84,7 +84,7 @@
</thead> </thead>
<tbody> <tbody>
<tr ng-repeat="container in containersUsingVolume"> <tr ng-repeat="container in containersUsingVolume">
<td><a ui-sref="docker.containers.container({id: container.Id})">{{ container | containername }}</a></td> <td><a ui-sref="docker.containers.container({ id: container.Id, nodeName: container.NodeName })">{{ container | containername }}</a></td>
<td>{{ container.volumeData.Destination }}</td> <td>{{ container.volumeData.Destination }}</td>
<td>{{ !container.volumeData.RW }}</td> <td>{{ !container.volumeData.RW }}</td>
</tr> </tr>

View file

@ -1,6 +1,6 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('VolumeController', ['$scope', '$state', '$transition$', 'VolumeService', 'ContainerService', 'Notifications', .controller('VolumeController', ['$scope', '$state', '$transition$', 'VolumeService', 'ContainerService', 'Notifications', 'HttpRequestHelper',
function ($scope, $state, $transition$, VolumeService, ContainerService, Notifications) { function ($scope, $state, $transition$, VolumeService, ContainerService, Notifications, HttpRequestHelper) {
$scope.removeVolume = function removeVolume() { $scope.removeVolume = function removeVolume() {
VolumeService.remove($scope.volume) VolumeService.remove($scope.volume)
@ -20,6 +20,7 @@ function ($scope, $state, $transition$, VolumeService, ContainerService, Notific
} }
function initView() { function initView() {
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
VolumeService.volume($transition$.params().id) VolumeService.volume($transition$.params().id)
.then(function success(data) { .then(function success(data) {
var volume = data; var volume = data;

View file

@ -15,6 +15,7 @@
order-by="Id" show-text-filter="true" order-by="Id" show-text-filter="true"
remove-action="removeAction" remove-action="removeAction"
show-ownership-column="applicationState.application.authentication" show-ownership-column="applicationState.application.authentication"
show-host-column="applicationState.endpoint.mode.agentProxy"
></volumes-datatable> ></volumes-datatable>
</div> </div>
</div> </div>

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