Compare commits

...

76 Commits

Author SHA1 Message Date
Anthony Lapenna
9e06cfbdf0 Merge branch 'release/1.12.2' 2017-03-28 15:18:33 +02:00
Anthony Lapenna
135a92feb4 chore(version): bump version number 2017-03-28 15:18:29 +02:00
Anthony Lapenna
cd4b5e0c80 docs(README): update supported versions 2017-03-28 15:17:49 +02:00
Anthony Lapenna
3cd0506810 feat(build): update build script 2017-03-28 15:16:42 +02:00
Thomas Krzero
ffa2cf62f5 feat(services) - add exposed ports (#690) 2017-03-28 15:12:54 +02:00
Anthony Lapenna
0e439d7ae6 fix(Dockerfiles): use a volume to store data (#731) 2017-03-28 15:07:42 +02:00
Anthony Lapenna
a99c6c4cbe fix(backend): use a thread-safe implementation of map for proxies (#728) 2017-03-28 14:28:17 +02:00
Anthony Lapenna
9e818c2882 fix(authentication): remove any user credentials if not allowed on any endpoint (#719) 2017-03-27 15:24:35 +02:00
Anthony Lapenna
c243a02e7a feat(UX): UX/responsiveness enhancements 2017-03-27 14:44:39 +02:00
Anthony Lapenna
967286f45d docs(contributing): update contribution guidelines 2017-03-24 12:22:58 +01:00
dantheman0207
8e794be13f feat(containers): truncate long names & ids in the containers view (#699) 2017-03-22 08:13:59 +01:00
Glowbal
a8f70d7f59 feat(service-details): add ability to edit service details (#453) 2017-03-20 21:28:09 +01:00
Anthony Lapenna
ab91ffe12c style(containers): use the same action sequence for container-details and containers (#707) 2017-03-20 17:39:53 +01:00
Anthony Lapenna
24b51a7e87 refactor(image): refactor the code used in image and image details controller (#705) 2017-03-20 12:01:35 +01:00
Gábor Kovács
c2e63070e6 feat(image-details): add the ability to pull/update a tag (#421) 2017-03-20 11:45:04 +01:00
AHumanPerson
b6627098c2 docs(README): update demo username (#703) 2017-03-19 21:24:09 +01:00
Anthony Lapenna
097955e587 fix(templates): fix an issue where container links would fail (#701) 2017-03-19 19:07:22 +01:00
Anthony Lapenna
497a8392f6 fix(sidebar): fix a display issue on low resolution (#697) 2017-03-18 13:08:39 +01:00
Anthony Lapenna
dcce211676 fix(api): allow empty array when removing accesses to an endpoint (#692) 2017-03-17 11:52:17 +01:00
Anthony Lapenna
631b29eddc fix(jshint): fix lint issues 2017-03-16 11:32:07 +01:00
Anthony Lapenna
9f12cbd43d fix(services): fix an issue with the sorting link for the ownership column (#682) 2017-03-16 11:24:47 +01:00
Anthony Lapenna
b24825d453 feat(backend): check for the full database path to verify its existence (#681) 2017-03-16 11:23:01 +01:00
Anthony Lapenna
3861e964f4 fix(dockerfile): fix an issue with the data directory in Windows images 2017-03-14 18:28:21 +01:00
Anthony Lapenna
ca4428cff2 feat(build): update build script 2017-03-13 10:23:49 +01:00
Anthony Lapenna
6b09c4f9b7 Merge tag '1.12.1' into develop
Release 1.12.1
2017-03-13 10:12:55 +01:00
Anthony Lapenna
5b2d5e17ab Merge branch 'release/1.12.1' 2017-03-13 10:12:49 +01:00
Anthony Lapenna
be2acdbdfb chore(version): bump version number 2017-03-13 10:12:42 +01:00
Anthony Lapenna
723bf3874f fix(templates): fix an issue where the image would not be pulled correctly (#664) 2017-03-13 10:09:34 +01:00
Anthony Lapenna
ebc378230f Merge tag '1.12.0' into develop
Release 1.12.0
2017-03-12 22:33:40 +01:00
Anthony Lapenna
7bef9c0708 Merge branch 'release/1.12.0' 2017-03-12 22:33:34 +01:00
Anthony Lapenna
1294ebaa8c chore(version): bump version number 2017-03-12 22:33:26 +01:00
Anthony Lapenna
f40baa1287 feat(build): update build script 2017-03-12 22:30:50 +01:00
Richard Goater
35e2cecee1 feat(services): display clearer information about services 2017-03-12 18:24:41 +01:00
Anthony Lapenna
22c02a8fe9 fix(swarm): fix an issue when trying to access node view (#650) 2017-03-12 18:01:52 +01:00
Michael Friis
08868eb3e0 refactor(endpoint-init): update information warning for the local endpoint management 2017-03-12 17:43:33 +01:00
Damian
8a827950d8 Ability to select all endpoints via a checkbox (#607) 2017-03-12 17:39:27 +01:00
Anthony Lapenna
d724f75016 fix(app): use lodash startsWith method instead of ECMAScript 2015 one (#648) 2017-03-12 17:36:24 +01:00
Anthony Lapenna
80d50378c5 feat(uac): add multi user management and UAC (#647) 2017-03-12 17:24:15 +01:00
WTFKr0
f28f223624 #643 feat(templates): add privileged flag to templates (#644) 2017-03-10 15:43:57 +01:00
Anthony Lapenna
082cf5772b merge remote branch 'develop' into develop 2017-03-03 13:07:16 +01:00
Anthony Lapenna
44ceae40b5 merge branch 'release-1.11.4' into develop 2017-03-03 12:54:22 +01:00
Anthony Lapenna
b72cce810e Merge branch 'release/1.11.4' 2017-03-03 12:48:12 +01:00
Anthony Lapenna
ccaabf3b6b chore(version): bump version number 2017-03-03 12:36:24 +01:00
Anthony Lapenna
2232adbd8b merge branch 'feat484-external-endpoints' into release-1.11.4 2017-03-03 12:35:54 +01:00
WTFKr0
cff999d7bb refactor(global): change file format (dos2unix) (#620) 2017-02-25 12:21:55 +01:00
Anthony Lapenna
ec0cc84c7c refactor(lint): fix lint issue 2017-02-16 11:23:43 +13:00
Romain
64ef74321a feat(image): add the ability to force remove an image (#497) (#562) 2017-02-16 11:14:56 +13:00
Romain
6f53d1a35a feat (container): remember selection when refreshing a list view (#151) (#567) 2017-02-16 11:08:18 +13:00
Renato Silva
f1c458b147 feat(container-creation): add the ability to add entries in the container host file 2017-02-16 10:48:40 +13:00
Anthony Lapenna
38244312c5 fix(stats): fix a small issue within statsController 2017-02-14 17:10:08 +13:00
Anthony Lapenna
52ab0bd50d feat(UX): automatically change the state to dashboard when switching endpoint (#602) 2017-02-14 16:22:24 +13:00
Anthony Lapenna
73082f1674 feat(cli): add a --no-analytics flag to disable google analytics (#601) 2017-02-14 12:37:37 +13:00
Anthony Lapenna
66c574f74d feat(project): add google analytics in app (#599) 2017-02-14 11:39:26 +13:00
Anthony Lapenna
85a07237b1 feat(swarm): display the IP address of each node when API Version >= … (#595) 2017-02-13 22:39:02 +13:00
Anthony Lapenna
781dad3e17 feat(templates): add the ability to update the volume configuration (#590) 2017-02-13 18:16:14 +13:00
Romain
c5552d1b8e feat (container): add publish all ports option (#558) (#566) 2017-02-12 12:23:13 +13:00
Anthony Lapenna
e0b94e4ff7 feat(templates): add support for the network field (#583) 2017-02-11 09:32:34 +13:00
Anthony Lapenna
3089268d88 fix(container-creation): split the container command to a token array (#586) 2017-02-10 18:21:07 +13:00
Anthony Lapenna
d9624053d2 feat(templates): add support for the command field (#585) 2017-02-10 18:11:00 +13:00
Anthony Lapenna
9ebe2d96dd chore(jshint): update jshint library and configuration (#581) 2017-02-10 14:34:56 +13:00
Anthony Lapenna
2f3475b96a refactor(templates): refactor controller code and create required services (#580) 2017-02-10 14:11:36 +13:00
Samuel Tschiedel
06a484880b fix(index): fix a typo on the login page (#579) 2017-02-10 09:32:34 +13:00
Anthony Lapenna
a78758123b style(cli): update error message 2017-02-07 16:27:40 +13:00
Anthony Lapenna
f129bf3e97 refactor(api): refactor 2017-02-07 16:26:12 +13:00
Anthony Lapenna
dc78ec5135 feat(endpoints): add the ability to define endpoints from an external source 2017-02-06 18:29:34 +13:00
Anthony Lapenna
10f7744a62 feat(authentication): add a --no-auth flag to disable authentication (#553) 2017-02-01 22:13:48 +13:00
Anthony Lapenna
0f81ad5654 feat(global): add a --no-auth flag to disable authentication 2017-02-01 22:10:07 +13:00
Anthony Lapenna
779fcf8e7f refactor(readme): remove useless version badge 2017-02-01 15:42:15 +13:00
Anthony Lapenna
7c2b186a61 refactor(assets): remove useless .jshintrc file 2017-02-01 15:40:49 +13:00
Anthony Lapenna
fe0bf77bbb refactor(global): service separation #552 2017-02-01 12:26:29 +13:00
Anthony Lapenna
0abe8883d1 chore(dockerfiles): update data directory for windows Dockerfiles 2017-02-01 11:35:25 +13:00
Anthony Lapenna
84f2c2d735 Merge tag '1.11.3' into develop
Release 1.11.3
2017-02-01 11:02:15 +13:00
Anthony Lapenna
5d63c90203 Merge branch 'release/1.11.3' 2017-02-01 11:02:10 +13:00
Anthony Lapenna
a97e7bbaae chore(version): bump version number 2017-02-01 11:02:05 +13:00
Anthony Lapenna
f3cfb0a940 fix(cli): revert data/certs directories defaults to c:\data and c:\certs (#551) 2017-02-01 08:56:07 +13:00
Anthony Lapenna
b1ca43934f Merge tag '1.11.2' into develop
Release 1.11.2
2017-01-26 17:44:00 +13:00
164 changed files with 8990 additions and 3753 deletions

View File

@@ -22,11 +22,13 @@ Some of the open issues are labeled with prefix `exp/`, this is used to mark the
* **beginner**: a task that should be accessible with users not familiar with the codebase
* **intermediate**: a task that require some understanding of the project codebase or some experience in
either AngularJS or Golang
* **advanced**: a task that require a deep understanding of the project codebase
You can have a use Github filters to list these issues:
* beginner labeled issues: https://github.com/portainer/portainer/labels/exp%2Fbeginner
* intermediate labeled issues: https://github.com/portainer/portainer/labels/exp%2Fintermediate
* advanced labeled issues: https://github.com/portainer/portainer/labels/exp%2Fadvanced
### Linting

View File

@@ -3,7 +3,6 @@
<img title="portainer" src='http://portainer.io/images/logo_alt.png' />
</p>
[![Microbadger version](https://images.microbadger.com/badges/version/portainer/portainer.svg)](https://microbadger.com/images/portainer/portainer "Latest version on Docker Hub")
[![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer "Image size")
[![Documentation Status](https://readthedocs.org/projects/portainer/badge/?version=stable)](http://portainer.readthedocs.io/en/latest/?badge=stable)
[![Gitter](https://badges.gitter.im/portainer/Lobby.svg)](https://gitter.im/portainer/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
@@ -19,7 +18,7 @@
<img src="http://portainer.io/images/screenshots/portainer.gif" width="77%"/>
You can try out the public demo instance: http://demo.portainer.io/ (login with the username **demo** and the password **tryportainer**).
You can try out the public demo instance: http://demo.portainer.io/ (login with the username **admin** and the password **tryportainer**).
Please note that the public demo cluster is **reset every 15min**.
@@ -44,7 +43,7 @@ Please note that the public demo cluster is **reset every 15min**.
**_Portainer_** has full support for the following Docker versions:
* Docker 1.10 to Docker 1.12 (including `swarm-mode`)
* Docker 1.10 to Docker 17.03 (including `swarm-mode`)
* Docker Swarm >= 1.2.3
Partial support for the following Docker versions (some features may not be available):

View File

@@ -0,0 +1,76 @@
package bolt
import (
"github.com/boltdb/bolt"
"github.com/portainer/portainer"
)
type Migrator struct {
UserService *UserService
EndpointService *EndpointService
ResourceControlService *ResourceControlService
VersionService *VersionService
CurrentDBVersion int
store *Store
}
func NewMigrator(store *Store, version int) *Migrator {
return &Migrator{
UserService: store.UserService,
EndpointService: store.EndpointService,
ResourceControlService: store.ResourceControlService,
VersionService: store.VersionService,
CurrentDBVersion: version,
store: store,
}
}
func (m *Migrator) Migrate() error {
// Portainer < 1.12
if m.CurrentDBVersion == 0 {
err := m.updateAdminUser()
if err != nil {
return err
}
}
err := m.VersionService.StoreDBVersion(portainer.DBVersion)
if err != nil {
return err
}
return nil
}
func (m *Migrator) updateAdminUser() error {
u, err := m.UserService.UserByUsername("admin")
if err == nil {
admin := &portainer.User{
Username: "admin",
Password: u.Password,
Role: portainer.AdministratorRole,
}
err = m.UserService.CreateUser(admin)
if err != nil {
return err
}
err = m.removeLegacyAdminUser()
if err != nil {
return err
}
} else if err != nil && err != portainer.ErrUserNotFound {
return err
}
return nil
}
func (m *Migrator) removeLegacyAdminUser() error {
return m.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
err := bucket.Delete([]byte("admin"))
if err != nil {
return err
}
return nil
})
}

View File

@@ -1,9 +1,12 @@
package bolt
import (
"log"
"os"
"time"
"github.com/boltdb/bolt"
"github.com/portainer/portainer"
)
// Store defines the implementation of portainer.DataStore using
@@ -13,29 +16,49 @@ type Store struct {
Path string
// Services
UserService *UserService
EndpointService *EndpointService
UserService *UserService
EndpointService *EndpointService
ResourceControlService *ResourceControlService
VersionService *VersionService
db *bolt.DB
db *bolt.DB
checkForDataMigration bool
}
const (
databaseFileName = "portainer.db"
userBucketName = "users"
endpointBucketName = "endpoints"
activeEndpointBucketName = "activeEndpoint"
databaseFileName = "portainer.db"
versionBucketName = "version"
userBucketName = "users"
endpointBucketName = "endpoints"
containerResourceControlBucketName = "containerResourceControl"
serviceResourceControlBucketName = "serviceResourceControl"
volumeResourceControlBucketName = "volumeResourceControl"
)
// NewStore initializes a new Store and the associated services
func NewStore(storePath string) *Store {
func NewStore(storePath string) (*Store, error) {
store := &Store{
Path: storePath,
UserService: &UserService{},
EndpointService: &EndpointService{},
Path: storePath,
UserService: &UserService{},
EndpointService: &EndpointService{},
ResourceControlService: &ResourceControlService{},
VersionService: &VersionService{},
}
store.UserService.store = store
store.EndpointService.store = store
return store
store.ResourceControlService.store = store
store.VersionService.store = store
_, err := os.Stat(storePath + "/" + databaseFileName)
if err != nil && os.IsNotExist(err) {
store.checkForDataMigration = false
} else if err != nil {
return nil, err
} else {
store.checkForDataMigration = true
}
return store, nil
}
// Open opens and initializes the BoltDB database.
@@ -47,7 +70,11 @@ func (store *Store) Open() error {
}
store.db = db
return db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte(userBucketName))
_, err := tx.CreateBucketIfNotExists([]byte(versionBucketName))
if err != nil {
return err
}
_, err = tx.CreateBucketIfNotExists([]byte(userBucketName))
if err != nil {
return err
}
@@ -55,7 +82,15 @@ func (store *Store) Open() error {
if err != nil {
return err
}
_, err = tx.CreateBucketIfNotExists([]byte(activeEndpointBucketName))
_, err = tx.CreateBucketIfNotExists([]byte(containerResourceControlBucketName))
if err != nil {
return err
}
_, err = tx.CreateBucketIfNotExists([]byte(serviceResourceControlBucketName))
if err != nil {
return err
}
_, err = tx.CreateBucketIfNotExists([]byte(volumeResourceControlBucketName))
if err != nil {
return err
}
@@ -70,3 +105,32 @@ func (store *Store) Close() error {
}
return nil
}
// MigrateData automatically migrate the data based on the DBVersion.
func (store *Store) MigrateData() error {
if !store.checkForDataMigration {
err := store.VersionService.StoreDBVersion(portainer.DBVersion)
if err != nil {
return err
}
return nil
}
version, err := store.VersionService.DBVersion()
if err == portainer.ErrDBVersionNotFound {
version = 0
} else if err != nil {
return err
}
if version < portainer.DBVersion {
log.Printf("Migrating database from version %v to %v.\n", version, portainer.DBVersion)
migrator := NewMigrator(store, version)
err = migrator.Migrate()
if err != nil {
return err
}
}
return nil
}

View File

@@ -12,10 +12,6 @@ type EndpointService struct {
store *Store
}
const (
activeEndpointID = 0
)
// Endpoint returns an endpoint by ID.
func (service *EndpointService) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) {
var data []byte
@@ -67,20 +63,41 @@ func (service *EndpointService) Endpoints() ([]portainer.Endpoint, error) {
return endpoints, nil
}
// Synchronize creates, updates and deletes endpoints inside a single transaction.
func (service *EndpointService) Synchronize(toCreate, toUpdate, toDelete []*portainer.Endpoint) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointBucketName))
for _, endpoint := range toCreate {
err := storeNewEndpoint(endpoint, bucket)
if err != nil {
return err
}
}
for _, endpoint := range toUpdate {
err := marshalAndStoreEndpoint(endpoint, bucket)
if err != nil {
return err
}
}
for _, endpoint := range toDelete {
err := bucket.Delete(internal.Itob(int(endpoint.ID)))
if err != nil {
return err
}
}
return nil
})
}
// CreateEndpoint assign an ID to a new endpoint and saves it.
func (service *EndpointService) CreateEndpoint(endpoint *portainer.Endpoint) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointBucketName))
id, _ := bucket.NextSequence()
endpoint.ID = portainer.EndpointID(id)
data, err := internal.MarshalEndpoint(endpoint)
if err != nil {
return err
}
err = bucket.Put(internal.Itob(int(endpoint.ID)), data)
err := storeNewEndpoint(endpoint, bucket)
if err != nil {
return err
}
@@ -117,58 +134,21 @@ func (service *EndpointService) DeleteEndpoint(ID portainer.EndpointID) error {
})
}
// GetActive returns the active endpoint.
func (service *EndpointService) GetActive() (*portainer.Endpoint, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(activeEndpointBucketName))
value := bucket.Get(internal.Itob(activeEndpointID))
if value == nil {
return portainer.ErrEndpointNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
func marshalAndStoreEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error {
data, err := internal.MarshalEndpoint(endpoint)
if err != nil {
return nil, err
return err
}
var endpoint portainer.Endpoint
err = internal.UnmarshalEndpoint(data, &endpoint)
err = bucket.Put(internal.Itob(int(endpoint.ID)), data)
if err != nil {
return nil, err
return err
}
return &endpoint, nil
return nil
}
// SetActive saves an endpoint as active.
func (service *EndpointService) SetActive(endpoint *portainer.Endpoint) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(activeEndpointBucketName))
data, err := internal.MarshalEndpoint(endpoint)
if err != nil {
return err
}
err = bucket.Put(internal.Itob(activeEndpointID), data)
if err != nil {
return err
}
return nil
})
}
// DeleteActive deletes the active endpoint.
func (service *EndpointService) DeleteActive() error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(activeEndpointBucketName))
err := bucket.Delete(internal.Itob(activeEndpointID))
if err != nil {
return err
}
return nil
})
func storeNewEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error {
id, _ := bucket.NextSequence()
endpoint.ID = portainer.EndpointID(id)
return marshalAndStoreEndpoint(endpoint, bucket)
}

View File

@@ -27,6 +27,16 @@ func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error {
return json.Unmarshal(data, endpoint)
}
// MarshalResourceControl encodes a resource control object to binary format.
func MarshalResourceControl(rc *portainer.ResourceControl) ([]byte, error) {
return json.Marshal(rc)
}
// UnmarshalResourceControl decodes a resource control object from a binary data.
func UnmarshalResourceControl(data []byte, rc *portainer.ResourceControl) error {
return json.Unmarshal(data, rc)
}
// Itob returns an 8-byte big endian representation of v.
// This function is typically used for encoding integer IDs to byte slices
// so that they can be used as BoltDB keys.

View File

@@ -0,0 +1,110 @@
package bolt
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
// ResourceControlService represents a service for managing resource controls.
type ResourceControlService struct {
store *Store
}
func getBucketNameByResourceControlType(rcType portainer.ResourceControlType) string {
bucketName := containerResourceControlBucketName
if rcType == portainer.ServiceResourceControl {
bucketName = serviceResourceControlBucketName
} else if rcType == portainer.VolumeResourceControl {
bucketName = volumeResourceControlBucketName
}
return bucketName
}
// ResourceControl returns a resource control object by resource ID
func (service *ResourceControlService) ResourceControl(resourceID string, rcType portainer.ResourceControlType) (*portainer.ResourceControl, error) {
var data []byte
bucketName := getBucketNameByResourceControlType(rcType)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(bucketName))
value := bucket.Get([]byte(resourceID))
if value == nil {
return nil
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
if data == nil {
return nil, nil
}
var rc portainer.ResourceControl
err = internal.UnmarshalResourceControl(data, &rc)
if err != nil {
return nil, err
}
return &rc, nil
}
// ResourceControls returns all resource control objects
func (service *ResourceControlService) ResourceControls(rcType portainer.ResourceControlType) ([]portainer.ResourceControl, error) {
var rcs = make([]portainer.ResourceControl, 0)
bucketName := getBucketNameByResourceControlType(rcType)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(bucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var rc portainer.ResourceControl
err := internal.UnmarshalResourceControl(v, &rc)
if err != nil {
return err
}
rcs = append(rcs, rc)
}
return nil
})
if err != nil {
return nil, err
}
return rcs, nil
}
// CreateResourceControl creates a new resource control
func (service *ResourceControlService) CreateResourceControl(resourceID string, rc *portainer.ResourceControl, rcType portainer.ResourceControlType) error {
bucketName := getBucketNameByResourceControlType(rcType)
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(bucketName))
data, err := internal.MarshalResourceControl(rc)
if err != nil {
return err
}
err = bucket.Put([]byte(resourceID), data)
if err != nil {
return err
}
return nil
})
}
// DeleteResourceControl deletes a resource control object by resource ID
func (service *ResourceControlService) DeleteResourceControl(resourceID string, rcType portainer.ResourceControlType) error {
bucketName := getBucketNameByResourceControlType(rcType)
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(bucketName))
err := bucket.Delete([]byte(resourceID))
if err != nil {
return err
}
return nil
})
}

View File

@@ -12,12 +12,12 @@ type UserService struct {
store *Store
}
// User returns a user by username.
func (service *UserService) User(username string) (*portainer.User, error) {
// User returns a user by ID
func (service *UserService) User(ID portainer.UserID) (*portainer.User, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
value := bucket.Get([]byte(username))
value := bucket.Get(internal.Itob(int(ID)))
if value == nil {
return portainer.ErrUserNotFound
}
@@ -38,8 +38,88 @@ func (service *UserService) User(username string) (*portainer.User, error) {
return &user, nil
}
// UserByUsername returns a user by username.
func (service *UserService) UserByUsername(username string) (*portainer.User, error) {
var user *portainer.User
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var u portainer.User
err := internal.UnmarshalUser(v, &u)
if err != nil {
return err
}
if u.Username == username {
user = &u
}
}
if user == nil {
return portainer.ErrUserNotFound
}
return nil
})
if err != nil {
return nil, err
}
return user, nil
}
// Users return an array containing all the users.
func (service *UserService) Users() ([]portainer.User, error) {
var users = make([]portainer.User, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var user portainer.User
err := internal.UnmarshalUser(v, &user)
if err != nil {
return err
}
users = append(users, user)
}
return nil
})
if err != nil {
return nil, err
}
return users, nil
}
// UsersByRole return an array containing all the users with the specified role.
func (service *UserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
var users = make([]portainer.User, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var user portainer.User
err := internal.UnmarshalUser(v, &user)
if err != nil {
return err
}
if user.Role == role {
users = append(users, user)
}
}
return nil
})
if err != nil {
return nil, err
}
return users, nil
}
// UpdateUser saves a user.
func (service *UserService) UpdateUser(user *portainer.User) error {
func (service *UserService) UpdateUser(ID portainer.UserID, user *portainer.User) error {
data, err := internal.MarshalUser(user)
if err != nil {
return err
@@ -47,7 +127,41 @@ func (service *UserService) UpdateUser(user *portainer.User) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
err = bucket.Put([]byte(user.Username), data)
err = bucket.Put(internal.Itob(int(ID)), data)
if err != nil {
return err
}
return nil
})
}
// CreateUser creates a new user.
func (service *UserService) CreateUser(user *portainer.User) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
id, _ := bucket.NextSequence()
user.ID = portainer.UserID(id)
data, err := internal.MarshalUser(user)
if err != nil {
return err
}
err = bucket.Put(internal.Itob(int(user.ID)), data)
if err != nil {
return err
}
return nil
})
}
// DeleteUser deletes a user.
func (service *UserService) DeleteUser(ID portainer.UserID) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
err := bucket.Delete(internal.Itob(int(ID)))
if err != nil {
return err
}

View File

@@ -0,0 +1,58 @@
package bolt
import (
"strconv"
"github.com/portainer/portainer"
"github.com/boltdb/bolt"
)
// EndpointService represents a service for managing users.
type VersionService struct {
store *Store
}
const (
DBVersionKey = "DB_VERSION"
)
// DBVersion the stored database version.
func (service *VersionService) DBVersion() (int, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(versionBucketName))
value := bucket.Get([]byte(DBVersionKey))
if value == nil {
return portainer.ErrDBVersionNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return 0, err
}
dbVersion, err := strconv.Atoi(string(data))
if err != nil {
return 0, err
}
return dbVersion, nil
}
// StoreDBVersion store the database version.
func (service *VersionService) StoreDBVersion(version int) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(versionBucketName))
data := []byte(strconv.Itoa(version))
err := bucket.Put([]byte(DBVersionKey), data)
if err != nil {
return err
}
return nil
})
}

View File

@@ -1,6 +1,8 @@
package cli
import (
"time"
"github.com/portainer/portainer"
"os"
@@ -13,8 +15,11 @@ import (
type Service struct{}
const (
errInvalidEnpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://")
errSocketNotFound = portainer.Error("Unable to locate Unix socket")
errInvalidEnpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://")
errSocketNotFound = portainer.Error("Unable to locate Unix socket")
errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file")
errInvalidSyncInterval = portainer.Error("Invalid synchronization interval")
errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints")
)
// ParseFlags parse the CLI flags and return a portainer.Flags struct
@@ -22,17 +27,21 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
kingpin.Version(version)
flags := &portainer.CLIFlags{
Endpoint: kingpin.Flag("host", "Dockerd endpoint").Short('H').String(),
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(),
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Default(defaultTemplatesURL).Short('t').String(),
TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(),
TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).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(),
Endpoint: kingpin.Flag("host", "Dockerd endpoint").Short('H').String(),
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
ExternalEndpoints: kingpin.Flag("external-endpoints", "Path to a file defining available endpoints").String(),
SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).String(),
Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(),
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Default(defaultTemplatesURL).Short('t').String(),
NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(),
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAuth).Bool(),
TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(),
TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).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(),
}
kingpin.Parse()
@@ -41,13 +50,37 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
// ValidateFlags validates the values of the flags.
func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
if *flags.Endpoint != "" {
if !strings.HasPrefix(*flags.Endpoint, "unix://") && !strings.HasPrefix(*flags.Endpoint, "tcp://") {
if *flags.Endpoint != "" && *flags.ExternalEndpoints != "" {
return errEndpointExcludeExternal
}
err := validateEndpoint(*flags.Endpoint)
if err != nil {
return err
}
err = validateExternalEndpoints(*flags.ExternalEndpoints)
if err != nil {
return err
}
err = validateSyncInterval(*flags.SyncInterval)
if err != nil {
return err
}
return nil
}
func validateEndpoint(endpoint string) error {
if endpoint != "" {
if !strings.HasPrefix(endpoint, "unix://") && !strings.HasPrefix(endpoint, "tcp://") {
return errInvalidEnpointProtocol
}
if strings.HasPrefix(*flags.Endpoint, "unix://") {
socketPath := strings.TrimPrefix(*flags.Endpoint, "unix://")
if strings.HasPrefix(endpoint, "unix://") {
socketPath := strings.TrimPrefix(endpoint, "unix://")
if _, err := os.Stat(socketPath); err != nil {
if os.IsNotExist(err) {
return errSocketNotFound
@@ -56,6 +89,27 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
}
}
}
return nil
}
func validateExternalEndpoints(externalEndpoints string) error {
if externalEndpoints != "" {
if _, err := os.Stat(externalEndpoints); err != nil {
if os.IsNotExist(err) {
return errEndpointsFileNotFound
}
return err
}
}
return nil
}
func validateSyncInterval(syncInterval string) error {
if syncInterval != defaultSyncInterval {
_, err := time.ParseDuration(syncInterval)
if err != nil {
return errInvalidSyncInterval
}
}
return nil
}

View File

@@ -7,8 +7,11 @@ const (
defaultDataDirectory = "/data"
defaultAssetsDirectory = "."
defaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLSVerify = "false"
defaultTLSCACertPath = "/certs/ca.pem"
defaultTLSCertPath = "/certs/cert.pem"
defaultTLSKeyPath = "/certs/key.pem"
defaultSyncInterval = "60s"
)

View File

@@ -2,11 +2,14 @@ package cli
const (
defaultBindAddress = ":9000"
defaultDataDirectory = "C:\\ProgramData\\Portainer"
defaultDataDirectory = "C:\\data"
defaultAssetsDirectory = "."
defaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLSVerify = "false"
defaultTLSCACertPath = "C:\\ProgramData\\Portainer\\certs\\ca.pem"
defaultTLSCertPath = "C:\\ProgramData\\Portainer\\certs\\cert.pem"
defaultTLSKeyPath = "C:\\ProgramData\\Portainer\\certs\\key.pem"
defaultTLSCACertPath = "C:\\certs\\ca.pem"
defaultTLSCertPath = "C:\\certs\\cert.pem"
defaultTLSKeyPath = "C:\\certs\\key.pem"
defaultSyncInterval = "60s"
)

View File

@@ -4,6 +4,7 @@ import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt"
"github.com/portainer/portainer/cli"
"github.com/portainer/portainer/cron"
"github.com/portainer/portainer/crypto"
"github.com/portainer/portainer/file"
"github.com/portainer/portainer/http"
@@ -12,7 +13,7 @@ import (
"log"
)
func main() {
func initCLI() *portainer.CLIFlags {
var cli portainer.CLIService = &cli.Service{}
flags, err := cli.ParseFlags(portainer.APIVersion)
if err != nil {
@@ -23,38 +24,106 @@ func main() {
if err != nil {
log.Fatal(err)
}
return flags
}
settings := &portainer.Settings{
HiddenLabels: *flags.Labels,
Logo: *flags.Logo,
func initFileService(dataStorePath string) portainer.FileService {
fileService, err := file.NewService(dataStorePath, "")
if err != nil {
log.Fatal(err)
}
return fileService
}
fileService, err := file.NewService(*flags.Data, "")
func initStore(dataStorePath string) *bolt.Store {
store, err := bolt.NewStore(dataStorePath)
if err != nil {
log.Fatal(err)
}
var store = bolt.NewStore(*flags.Data)
err = store.Open()
if err != nil {
log.Fatal(err)
}
defer store.Close()
jwtService, err := jwt.NewService()
err = store.MigrateData()
if err != nil {
log.Fatal(err)
}
return store
}
var cryptoService portainer.CryptoService = &crypto.Service{}
func initJWTService(authenticationEnabled bool) portainer.JWTService {
if authenticationEnabled {
jwtService, err := jwt.NewService()
if err != nil {
log.Fatal(err)
}
return jwtService
}
return nil
}
func initCryptoService() portainer.CryptoService {
return &crypto.Service{}
}
func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool {
authorizeEndpointMgmt := true
if externalEnpointFile != "" {
authorizeEndpointMgmt = false
log.Println("Using external endpoint definition. Endpoint management via the API will be disabled.")
endpointWatcher := cron.NewWatcher(endpointService, syncInterval)
err := endpointWatcher.WatchEndpointFile(externalEnpointFile)
if err != nil {
log.Fatal(err)
}
}
return authorizeEndpointMgmt
}
func initSettings(authorizeEndpointMgmt bool, flags *portainer.CLIFlags) *portainer.Settings {
return &portainer.Settings{
HiddenLabels: *flags.Labels,
Logo: *flags.Logo,
Analytics: !*flags.NoAnalytics,
Authentication: !*flags.NoAuth,
EndpointManagement: authorizeEndpointMgmt,
}
}
func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService) *portainer.Endpoint {
endpoints, err := endpointService.Endpoints()
if err != nil {
log.Fatal(err)
}
return &endpoints[0]
}
func main() {
flags := initCLI()
fileService := initFileService(*flags.Data)
store := initStore(*flags.Data)
defer store.Close()
jwtService := initJWTService(!*flags.NoAuth)
cryptoService := initCryptoService()
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
settings := initSettings(authorizeEndpointMgmt, flags)
// Initialize the active endpoint from the CLI only if there is no
// active endpoint defined yet.
var activeEndpoint *portainer.Endpoint
if *flags.Endpoint != "" {
activeEndpoint, err = store.EndpointService.GetActive()
if err == portainer.ErrEndpointNotFound {
activeEndpoint = &portainer.Endpoint{
var endpoints []portainer.Endpoint
endpoints, err := store.EndpointService.Endpoints()
if err != nil {
log.Fatal(err)
}
if len(endpoints) == 0 {
endpoint := &portainer.Endpoint{
Name: "primary",
URL: *flags.Endpoint,
TLS: *flags.TLSVerify,
@@ -62,30 +131,32 @@ func main() {
TLSCertPath: *flags.TLSCert,
TLSKeyPath: *flags.TLSKey,
}
err = store.EndpointService.CreateEndpoint(activeEndpoint)
err = store.EndpointService.CreateEndpoint(endpoint)
if err != nil {
log.Fatal(err)
}
} else if err != nil {
log.Fatal(err)
} else {
log.Println("Instance already has defined endpoints. Skipping the endpoint defined via CLI.")
}
}
var server portainer.Server = &http.Server{
BindAddress: *flags.Addr,
AssetsPath: *flags.Assets,
Settings: settings,
TemplatesURL: *flags.Templates,
UserService: store.UserService,
EndpointService: store.EndpointService,
CryptoService: cryptoService,
JWTService: jwtService,
FileService: fileService,
ActiveEndpoint: activeEndpoint,
BindAddress: *flags.Addr,
AssetsPath: *flags.Assets,
Settings: settings,
TemplatesURL: *flags.Templates,
AuthDisabled: *flags.NoAuth,
EndpointManagement: authorizeEndpointMgmt,
UserService: store.UserService,
EndpointService: store.EndpointService,
ResourceControlService: store.ResourceControlService,
CryptoService: cryptoService,
JWTService: jwtService,
FileService: fileService,
}
log.Printf("Starting Portainer on %s", *flags.Addr)
err = server.Start()
err := server.Start()
if err != nil {
log.Fatal(err)
}

174
api/cron/endpoint_sync.go Normal file
View File

@@ -0,0 +1,174 @@
package cron
import (
"encoding/json"
"io/ioutil"
"log"
"os"
"strings"
"github.com/portainer/portainer"
)
type (
endpointSyncJob struct {
logger *log.Logger
endpointService portainer.EndpointService
endpointFilePath string
}
synchronization struct {
endpointsToCreate []*portainer.Endpoint
endpointsToUpdate []*portainer.Endpoint
endpointsToDelete []*portainer.Endpoint
}
)
const (
// ErrEmptyEndpointArray is an error raised when the external endpoint source array is empty.
ErrEmptyEndpointArray = portainer.Error("External endpoint source is empty")
)
func newEndpointSyncJob(endpointFilePath string, endpointService portainer.EndpointService) endpointSyncJob {
return endpointSyncJob{
logger: log.New(os.Stderr, "", log.LstdFlags),
endpointService: endpointService,
endpointFilePath: endpointFilePath,
}
}
func endpointSyncError(err error, logger *log.Logger) bool {
if err != nil {
logger.Printf("Endpoint synchronization error: %s", err)
return true
}
return false
}
func isValidEndpoint(endpoint *portainer.Endpoint) bool {
if endpoint.Name != "" && endpoint.URL != "" {
if !strings.HasPrefix(endpoint.URL, "unix://") && !strings.HasPrefix(endpoint.URL, "tcp://") {
return false
}
return true
}
return false
}
func endpointExists(endpoint *portainer.Endpoint, endpoints []portainer.Endpoint) int {
for idx, v := range endpoints {
if endpoint.Name == v.Name && isValidEndpoint(&v) {
return idx
}
}
return -1
}
func mergeEndpointIfRequired(original, updated *portainer.Endpoint) *portainer.Endpoint {
var endpoint *portainer.Endpoint
if original.URL != updated.URL || original.TLS != updated.TLS ||
(updated.TLS && original.TLSCACertPath != updated.TLSCACertPath) ||
(updated.TLS && original.TLSCertPath != updated.TLSCertPath) ||
(updated.TLS && original.TLSKeyPath != updated.TLSKeyPath) {
endpoint = original
endpoint.URL = updated.URL
if updated.TLS {
endpoint.TLS = true
endpoint.TLSCACertPath = updated.TLSCACertPath
endpoint.TLSCertPath = updated.TLSCertPath
endpoint.TLSKeyPath = updated.TLSKeyPath
} else {
endpoint.TLS = false
endpoint.TLSCACertPath = ""
endpoint.TLSCertPath = ""
endpoint.TLSKeyPath = ""
}
}
return endpoint
}
func (sync synchronization) requireSync() bool {
if len(sync.endpointsToCreate) != 0 || len(sync.endpointsToUpdate) != 0 || len(sync.endpointsToDelete) != 0 {
return true
}
return false
}
// TMP: endpointSyncJob method to access logger, should be generic
func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []portainer.Endpoint) *synchronization {
endpointsToCreate := make([]*portainer.Endpoint, 0)
endpointsToUpdate := make([]*portainer.Endpoint, 0)
endpointsToDelete := make([]*portainer.Endpoint, 0)
for idx := range storedEndpoints {
fidx := endpointExists(&storedEndpoints[idx], fileEndpoints)
if fidx != -1 {
endpoint := mergeEndpointIfRequired(&storedEndpoints[idx], &fileEndpoints[fidx])
if endpoint != nil {
job.logger.Printf("New definition for a stored endpoint found in file, updating database. [name: %v] [url: %v]\n", endpoint.Name, endpoint.URL)
endpointsToUpdate = append(endpointsToUpdate, endpoint)
} else {
job.logger.Printf("No change detected for a stored endpoint. [name: %v] [url: %v]\n", storedEndpoints[idx].Name, storedEndpoints[idx].URL)
}
} else {
job.logger.Printf("Stored endpoint not found in file (definition might be invalid), removing from database. [name: %v] [url: %v]", storedEndpoints[idx].Name, storedEndpoints[idx].URL)
endpointsToDelete = append(endpointsToDelete, &storedEndpoints[idx])
}
}
for idx, endpoint := range fileEndpoints {
if endpoint.Name == "" || endpoint.URL == "" {
job.logger.Printf("Invalid file endpoint definition, skipping. [name: %v] [url: %v]", endpoint.Name, endpoint.URL)
continue
}
sidx := endpointExists(&fileEndpoints[idx], storedEndpoints)
if sidx == -1 {
job.logger.Printf("File endpoint not found in database, adding to database. [name: %v] [url: %v]", fileEndpoints[idx].Name, fileEndpoints[idx].URL)
endpointsToCreate = append(endpointsToCreate, &fileEndpoints[idx])
}
}
return &synchronization{
endpointsToCreate: endpointsToCreate,
endpointsToUpdate: endpointsToUpdate,
endpointsToDelete: endpointsToDelete,
}
}
func (job endpointSyncJob) Sync() error {
data, err := ioutil.ReadFile(job.endpointFilePath)
if endpointSyncError(err, job.logger) {
return err
}
var fileEndpoints []portainer.Endpoint
err = json.Unmarshal(data, &fileEndpoints)
if endpointSyncError(err, job.logger) {
return err
}
if len(fileEndpoints) == 0 {
return ErrEmptyEndpointArray
}
storedEndpoints, err := job.endpointService.Endpoints()
if endpointSyncError(err, job.logger) {
return err
}
sync := job.prepareSyncData(storedEndpoints, fileEndpoints)
if sync.requireSync() {
err = job.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete)
if endpointSyncError(err, job.logger) {
return err
}
job.logger.Printf("Endpoint synchronization ended. [created: %v] [updated: %v] [deleted: %v]", len(sync.endpointsToCreate), len(sync.endpointsToUpdate), len(sync.endpointsToDelete))
}
return nil
}
func (job endpointSyncJob) Run() {
job.logger.Println("Endpoint synchronization job started.")
err := job.Sync()
endpointSyncError(err, job.logger)
}

40
api/cron/watcher.go Normal file
View File

@@ -0,0 +1,40 @@
package cron
import (
"github.com/portainer/portainer"
"github.com/robfig/cron"
)
// Watcher represents a service for managing crons.
type Watcher struct {
Cron *cron.Cron
EndpointService portainer.EndpointService
syncInterval string
}
// NewWatcher initializes a new service.
func NewWatcher(endpointService portainer.EndpointService, syncInterval string) *Watcher {
return &Watcher{
Cron: cron.New(),
EndpointService: endpointService,
syncInterval: syncInterval,
}
}
// WatchEndpointFile starts a cron job to synchronize the endpoints from a file
func (watcher *Watcher) WatchEndpointFile(endpointFilePath string) error {
job := newEndpointSyncJob(endpointFilePath, watcher.EndpointService)
err := job.Sync()
if err != nil {
return err
}
err = watcher.Cron.AddJob("@every "+watcher.syncInterval, job)
if err != nil {
return err
}
watcher.Cron.Start()
return nil
}

View File

@@ -2,19 +2,26 @@ package portainer
// General errors.
const (
ErrUnauthorized = Error("Unauthorized")
ErrUnauthorized = Error("Unauthorized")
ErrResourceAccessDenied = Error("Access denied to resource")
)
// User errors.
const (
ErrUserNotFound = Error("User not found")
ErrUserAlreadyExists = Error("User already exists")
ErrAdminAlreadyInitialized = Error("Admin user already initialized")
)
// Endpoint errors.
const (
ErrEndpointNotFound = Error("Endpoint not found")
ErrNoActiveEndpoint = Error("Undefined Docker endpoint")
ErrEndpointNotFound = Error("Endpoint not found")
ErrEndpointAccessDenied = Error("Access denied to endpoint")
)
// Version errors.
const (
ErrDBVersionNotFound = Error("DB version not found")
)
// Crypto errors.
@@ -24,8 +31,9 @@ const (
// JWT errors.
const (
ErrSecretGeneration = Error("Unable to generate secret key")
ErrInvalidJWTToken = Error("Invalid JWT token")
ErrSecretGeneration = Error("Unable to generate secret key")
ErrInvalidJWTToken = Error("Invalid JWT token")
ErrMissingContextData = Error("Unable to find JWT data in request context")
)
// File errors.

View File

@@ -16,6 +16,7 @@ import (
type AuthHandler struct {
*mux.Router
Logger *log.Logger
authDisabled bool
UserService portainer.UserService
CryptoService portainer.CryptoService
JWTService portainer.JWTService
@@ -26,15 +27,20 @@ const (
ErrInvalidCredentialsFormat = portainer.Error("Invalid credentials format")
// ErrInvalidCredentials is an error raised when credentials for a user are invalid
ErrInvalidCredentials = portainer.Error("Invalid credentials")
// ErrAuthDisabled is an error raised when trying to access the authentication endpoints
// when the server has been started with the --no-auth flag
ErrAuthDisabled = portainer.Error("Authentication is disabled")
)
// NewAuthHandler returns a new instance of AuthHandler.
func NewAuthHandler() *AuthHandler {
func NewAuthHandler(mw *middleWareService) *AuthHandler {
h := &AuthHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.HandleFunc("/auth", h.handlePostAuth)
h.Handle("/auth",
mw.public(http.HandlerFunc(h.handlePostAuth)))
return h
}
@@ -44,6 +50,11 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques
return
}
if handler.authDisabled {
Error(w, ErrAuthDisabled, http.StatusServiceUnavailable, handler.Logger)
return
}
var req postAuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
@@ -59,7 +70,7 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques
var username = req.Username
var password = req.Password
u, err := handler.UserService.User(username)
u, err := handler.UserService.UserByUsername(username)
if err == portainer.ErrUserNotFound {
Error(w, err, http.StatusNotFound, handler.Logger)
return
@@ -75,7 +86,9 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques
}
tokenData := &portainer.TokenData{
username,
ID: u.ID,
Username: u.Username,
Role: u.Role,
}
token, err := handler.JWTService.GenerateToken(tokenData)
if err != nil {

View File

@@ -1,159 +1,114 @@
package http
import (
"strconv"
"github.com/portainer/portainer"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"github.com/gorilla/mux"
"github.com/orcaman/concurrent-map"
)
// DockerHandler represents an HTTP API handler for proxying requests to the Docker API.
type DockerHandler struct {
*mux.Router
Logger *log.Logger
middleWareService *middleWareService
proxy http.Handler
Logger *log.Logger
EndpointService portainer.EndpointService
ProxyFactory ProxyFactory
proxies cmap.ConcurrentMap
}
// NewDockerHandler returns a new instance of DockerHandler.
func NewDockerHandler(middleWareService *middleWareService) *DockerHandler {
func NewDockerHandler(mw *middleWareService, resourceControlService portainer.ResourceControlService) *DockerHandler {
h := &DockerHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
middleWareService: middleWareService,
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
ProxyFactory: ProxyFactory{
ResourceControlService: resourceControlService,
},
proxies: cmap.New(),
}
h.PathPrefix("/").Handler(middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.proxyRequestsToDockerAPI(w, r)
})))
h.PathPrefix("/{id}/").Handler(
mw.authenticated(http.HandlerFunc(h.proxyRequestsToDockerAPI)))
return h
}
func checkEndpointAccessControl(endpoint *portainer.Endpoint, userID portainer.UserID) bool {
for _, authorizedUserID := range endpoint.AuthorizedUsers {
if authorizedUserID == userID {
return true
}
}
return false
}
func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) {
if handler.proxy != nil {
handler.proxy.ServeHTTP(w, r)
} else {
Error(w, portainer.ErrNoActiveEndpoint, http.StatusNotFound, handler.Logger)
}
}
vars := mux.Vars(r)
id := vars["id"]
func (handler *DockerHandler) setupProxy(endpoint *portainer.Endpoint) error {
var proxy http.Handler
endpointURL, err := url.Parse(endpoint.URL)
parsedID, err := strconv.Atoi(id)
if err != nil {
return err
Error(w, err, http.StatusBadRequest, handler.Logger)
return
}
if endpointURL.Scheme == "tcp" {
if endpoint.TLS {
proxy, err = newHTTPSProxy(endpointURL, endpoint)
if err != nil {
return err
}
} else {
proxy = newHTTPProxy(endpointURL)
endpointID := portainer.EndpointID(parsedID)
endpoint, err := handler.EndpointService.Endpoint(endpointID)
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
tokenData, err := extractTokenDataFromRequestContext(r)
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
}
if tokenData.Role != portainer.AdministratorRole && !checkEndpointAccessControl(endpoint, tokenData.ID) {
Error(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
return
}
var proxy http.Handler
item, ok := handler.proxies.Get(string(endpointID))
if !ok {
proxy, err = handler.createAndRegisterEndpointProxy(endpoint)
if err != nil {
Error(w, err, http.StatusBadRequest, handler.Logger)
return
}
} else {
// Assume unix:// scheme
proxy = newSocketProxy(endpointURL.Path)
proxy = item.(http.Handler)
}
handler.proxy = proxy
return nil
http.StripPrefix("/"+id, proxy).ServeHTTP(w, r)
}
// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go
// included here for use in NewSingleHostReverseProxyWithHostHeader
// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go
func singleJoiningSlash(a, b string) string {
aslash := strings.HasSuffix(a, "/")
bslash := strings.HasPrefix(b, "/")
switch {
case aslash && bslash:
return a + b[1:]
case !aslash && !bslash:
return a + "/" + b
}
return a + b
}
func (handler *DockerHandler) createAndRegisterEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
var proxy http.Handler
// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
// HTTP header, which NewSingleHostReverseProxy deliberately preserves
func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
targetQuery := target.RawQuery
director := func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
req.Host = req.URL.Host
if targetQuery == "" || req.URL.RawQuery == "" {
req.URL.RawQuery = targetQuery + req.URL.RawQuery
} else {
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
}
if _, ok := req.Header["User-Agent"]; !ok {
// explicitly disable User-Agent so it's not set to default value
req.Header.Set("User-Agent", "")
}
}
return &httputil.ReverseProxy{Director: director}
}
func newHTTPProxy(u *url.URL) http.Handler {
u.Scheme = "http"
return NewSingleHostReverseProxyWithHostHeader(u)
}
func newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) {
u.Scheme = "https"
proxy := NewSingleHostReverseProxyWithHostHeader(u)
config, err := createTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath)
endpointURL, err := url.Parse(endpoint.URL)
if err != nil {
return nil, err
}
proxy.Transport = &http.Transport{
TLSClientConfig: config,
if endpointURL.Scheme == "tcp" {
if endpoint.TLS {
proxy, err = handler.ProxyFactory.newHTTPSProxy(endpointURL, endpoint)
if err != nil {
return nil, err
}
} else {
proxy = handler.ProxyFactory.newHTTPProxy(endpointURL)
}
} else {
// Assume unix:// scheme
proxy = handler.ProxyFactory.newSocketProxy(endpointURL.Path)
}
handler.proxies.Set(string(endpoint.ID), proxy)
return proxy, nil
}
func newSocketProxy(path string) http.Handler {
return &unixSocketHandler{path}
}
// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket
type unixSocketHandler struct {
path string
}
func (h *unixSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
conn, err := net.Dial("unix", h.path)
if err != nil {
Error(w, err, http.StatusInternalServerError, nil)
return
}
c := httputil.NewClientConn(conn, nil)
defer c.Close()
res, err := c.Do(r)
if err != nil {
Error(w, err, http.StatusInternalServerError, nil)
return
}
defer res.Body.Close()
for k, vv := range res.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
if _, err := io.Copy(w, res.Body); err != nil {
Error(w, err, http.StatusInternalServerError, nil)
}
}

121
api/http/docker_proxy.go Normal file
View File

@@ -0,0 +1,121 @@
package http
import (
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"github.com/portainer/portainer"
)
// ProxyFactory is a factory to create reverse proxies to Docker endpoints
type ProxyFactory struct {
ResourceControlService portainer.ResourceControlService
}
// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go
// included here for use in NewSingleHostReverseProxyWithHostHeader
// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go
func singleJoiningSlash(a, b string) string {
aslash := strings.HasSuffix(a, "/")
bslash := strings.HasPrefix(b, "/")
switch {
case aslash && bslash:
return a + b[1:]
case !aslash && !bslash:
return a + "/" + b
}
return a + b
}
// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
// It also adds an extra Transport to the proxy to allow Portainer to rewrite the responses.
func (factory *ProxyFactory) newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
targetQuery := target.RawQuery
director := func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
req.Host = req.URL.Host
if targetQuery == "" || req.URL.RawQuery == "" {
req.URL.RawQuery = targetQuery + req.URL.RawQuery
} else {
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
}
if _, ok := req.Header["User-Agent"]; !ok {
// explicitly disable User-Agent so it's not set to default value
req.Header.Set("User-Agent", "")
}
}
transport := &proxyTransport{
ResourceControlService: factory.ResourceControlService,
transport: &http.Transport{},
}
return &httputil.ReverseProxy{Director: director, Transport: transport}
}
func (factory *ProxyFactory) newHTTPProxy(u *url.URL) http.Handler {
u.Scheme = "http"
return factory.newSingleHostReverseProxyWithHostHeader(u)
}
func (factory *ProxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) {
u.Scheme = "https"
proxy := factory.newSingleHostReverseProxyWithHostHeader(u)
config, err := createTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath)
if err != nil {
return nil, err
}
proxy.Transport.(*proxyTransport).transport.TLSClientConfig = config
return proxy, nil
}
func (factory *ProxyFactory) newSocketProxy(path string) http.Handler {
return &unixSocketHandler{path, &proxyTransport{
ResourceControlService: factory.ResourceControlService,
}}
}
// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket
type unixSocketHandler struct {
path string
transport *proxyTransport
}
func (h *unixSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
conn, err := net.Dial("unix", h.path)
if err != nil {
Error(w, err, http.StatusInternalServerError, nil)
return
}
c := httputil.NewClientConn(conn, nil)
defer c.Close()
res, err := c.Do(r)
if err != nil {
Error(w, err, http.StatusInternalServerError, nil)
return
}
defer res.Body.Close()
err = h.transport.proxyDockerRequests(r, res)
if err != nil {
Error(w, err, http.StatusInternalServerError, nil)
return
}
for k, vv := range res.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
if _, err := io.Copy(w, res.Body); err != nil {
Error(w, err, http.StatusInternalServerError, nil)
}
}

View File

@@ -16,38 +16,37 @@ import (
// EndpointHandler represents an HTTP API handler for managing Docker endpoints.
type EndpointHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
FileService portainer.FileService
server *Server
middleWareService *middleWareService
Logger *log.Logger
authorizeEndpointManagement bool
EndpointService portainer.EndpointService
FileService portainer.FileService
}
const (
// ErrEndpointManagementDisabled is an error raised when trying to access the endpoints management endpoints
// when the server has been started with the --external-endpoints flag
ErrEndpointManagementDisabled = portainer.Error("Endpoint management is disabled")
)
// NewEndpointHandler returns a new instance of EndpointHandler.
func NewEndpointHandler(middleWareService *middleWareService) *EndpointHandler {
func NewEndpointHandler(mw *middleWareService) *EndpointHandler {
h := &EndpointHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
middleWareService: middleWareService,
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/endpoints", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.handlePostEndpoints(w, r)
}))).Methods(http.MethodPost)
h.Handle("/endpoints", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.handleGetEndpoints(w, r)
}))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.handleGetEndpoint(w, r)
}))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.handlePutEndpoint(w, r)
}))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.handleDeleteEndpoint(w, r)
}))).Methods(http.MethodDelete)
h.Handle("/endpoints/{id}/active", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.handlePostEndpoint(w, r)
}))).Methods(http.MethodPost)
h.Handle("/endpoints",
mw.administrator(http.HandlerFunc(h.handlePostEndpoints))).Methods(http.MethodPost)
h.Handle("/endpoints",
mw.authenticated(http.HandlerFunc(h.handleGetEndpoints))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}",
mw.administrator(http.HandlerFunc(h.handleGetEndpoint))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}",
mw.administrator(http.HandlerFunc(h.handlePutEndpoint))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}/access",
mw.administrator(http.HandlerFunc(h.handlePutEndpointAccess))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}",
mw.administrator(http.HandlerFunc(h.handleDeleteEndpoint))).Methods(http.MethodDelete)
return h
}
@@ -58,13 +57,41 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, endpoints, handler.Logger)
tokenData, err := extractTokenDataFromRequestContext(r)
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
}
if tokenData == nil {
Error(w, portainer.ErrInvalidJWTToken, http.StatusBadRequest, handler.Logger)
return
}
var allowedEndpoints []portainer.Endpoint
if tokenData.Role != portainer.AdministratorRole {
allowedEndpoints = make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
for _, authorizedUserID := range endpoint.AuthorizedUsers {
if authorizedUserID == tokenData.ID {
allowedEndpoints = append(allowedEndpoints, endpoint)
break
}
}
}
} else {
allowedEndpoints = endpoints
}
encodeJSON(w, allowedEndpoints, handler.Logger)
}
// handlePostEndpoints handles POST requests on /endpoints
// if the active URL parameter is specified, will also define the new endpoint as the active endpoint.
// /endpoints(?active=true|false)
func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) {
if !handler.authorizeEndpointManagement {
Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger)
return
}
var req postEndpointsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
@@ -78,9 +105,10 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht
}
endpoint := &portainer.Endpoint{
Name: req.Name,
URL: req.URL,
TLS: req.TLS,
Name: req.Name,
URL: req.URL,
TLS: req.TLS,
AuthorizedUsers: []portainer.UserID{},
}
err = handler.EndpointService.CreateEndpoint(endpoint)
@@ -103,22 +131,6 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht
}
}
activeEndpointParameter := r.FormValue("active")
if activeEndpointParameter != "" {
active, err := strconv.ParseBool(activeEndpointParameter)
if err != nil {
Error(w, err, http.StatusBadRequest, handler.Logger)
return
}
if active == true {
err = handler.server.updateActiveEndpoint(endpoint)
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
}
encodeJSON(w, &postEndpointsResponse{ID: int(endpoint.ID)}, handler.Logger)
}
@@ -133,7 +145,6 @@ type postEndpointsResponse struct {
}
// handleGetEndpoint handles GET requests on /endpoints/:id
// GET /endpoints/0 returns active endpoint
func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
@@ -144,48 +155,6 @@ func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http
return
}
var endpoint *portainer.Endpoint
if id == "0" {
endpoint, err = handler.EndpointService.GetActive()
if err == portainer.ErrEndpointNotFound {
Error(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if handler.server.ActiveEndpoint == nil {
err = handler.server.updateActiveEndpoint(endpoint)
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
} else {
endpoint, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
Error(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
encodeJSON(w, endpoint, handler.Logger)
}
// handlePostEndpoint handles POST requests on /endpoints/:id/active
func (handler *EndpointHandler) handlePostEndpoint(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointID, err := strconv.Atoi(id)
if err != nil {
Error(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
Error(w, err, http.StatusNotFound, handler.Logger)
@@ -195,14 +164,65 @@ func (handler *EndpointHandler) handlePostEndpoint(w http.ResponseWriter, r *htt
return
}
err = handler.server.updateActiveEndpoint(endpoint)
encodeJSON(w, endpoint, handler.Logger)
}
// handlePutEndpointAccess handles PUT requests on /endpoints/:id/access
func (handler *EndpointHandler) handlePutEndpointAccess(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointID, err := strconv.Atoi(id)
if err != nil {
Error(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req putEndpointAccessRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
Error(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
authorizedUserIDs := []portainer.UserID{}
for _, value := range req.AuthorizedUsers {
authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value))
}
endpoint.AuthorizedUsers = authorizedUserIDs
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
type putEndpointAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
}
// handlePutEndpoint handles PUT requests on /endpoints/:id
func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http.Request) {
if !handler.authorizeEndpointManagement {
Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger)
return
}
vars := mux.Vars(r)
id := vars["id"]
@@ -224,14 +244,25 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
return
}
endpoint := &portainer.Endpoint{
ID: portainer.EndpointID(endpointID),
Name: req.Name,
URL: req.URL,
TLS: req.TLS,
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
Error(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if req.Name != "" {
endpoint.Name = req.Name
}
if req.URL != "" {
endpoint.URL = req.URL
}
if req.TLS {
endpoint.TLS = true
caCertPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCA)
endpoint.TLSCACertPath = caCertPath
certPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCert)
@@ -239,6 +270,10 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
keyPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileKey)
endpoint.TLSKeyPath = keyPath
} else {
endpoint.TLS = false
endpoint.TLSCACertPath = ""
endpoint.TLSCertPath = ""
endpoint.TLSKeyPath = ""
err = handler.FileService.DeleteTLSFiles(endpoint.ID)
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
@@ -254,14 +289,18 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
}
type putEndpointsRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
TLS bool
Name string `valid:"-"`
URL string `valid:"-"`
TLS bool `valid:"-"`
}
// handleDeleteEndpoint handles DELETE requests on /endpoints/:id
// DELETE /endpoints/0 deletes the active endpoint
func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) {
if !handler.authorizeEndpointManagement {
Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger)
return
}
vars := mux.Vars(r)
id := vars["id"]
@@ -271,13 +310,7 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h
return
}
var endpoint *portainer.Endpoint
if id == "0" {
endpoint, err = handler.EndpointService.GetActive()
endpointID = int(endpoint.ID)
} else {
endpoint, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
}
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
Error(w, err, http.StatusNotFound, handler.Logger)
@@ -292,13 +325,6 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if id == "0" {
err = handler.EndpointService.DeleteActive()
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
if endpoint.TLS {
err = handler.FileService.DeleteTLSFiles(portainer.EndpointID(endpointID))

View File

@@ -27,6 +27,10 @@ const (
ErrInvalidJSON = portainer.Error("Invalid JSON")
// ErrInvalidRequestFormat defines an error raised when the format of the data sent in a request is not valid
ErrInvalidRequestFormat = portainer.Error("Invalid request data format")
// ErrInvalidQueryFormat defines an error raised when the data sent in the query or the URL is invalid
ErrInvalidQueryFormat = portainer.Error("Invalid query format")
// ErrEmptyResponseBody defines an error raised when portainer excepts to parse the body of a HTTP response and there is nothing to parse
ErrEmptyResponseBody = portainer.Error("Empty response body")
)
// ServeHTTP delegates a request to the appropriate subhandler.

View File

@@ -1,32 +1,61 @@
package http
import (
"context"
"github.com/portainer/portainer"
"net/http"
"strings"
)
// Service represents a service to manage HTTP middlewares
type middleWareService struct {
jwtService portainer.JWTService
}
func addMiddleware(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
for _, mw := range middleware {
h = mw(h)
type (
// middleWareService represents a service to manage HTTP middlewares
middleWareService struct {
jwtService portainer.JWTService
authDisabled bool
}
contextKey int
)
const (
contextAuthenticationKey contextKey = iota
)
func extractTokenDataFromRequestContext(request *http.Request) (*portainer.TokenData, error) {
contextData := request.Context().Value(contextAuthenticationKey)
if contextData == nil {
return nil, portainer.ErrMissingContextData
}
tokenData := contextData.(*portainer.TokenData)
return tokenData, nil
}
// public defines a chain of middleware for public endpoints (no authentication required)
func (service *middleWareService) public(h http.Handler) http.Handler {
h = mwSecureHeaders(h)
return h
}
func (service *middleWareService) addMiddleWares(h http.Handler) http.Handler {
h = service.middleWareSecureHeaders(h)
h = service.middleWareAuthenticate(h)
// authenticated defines a chain of middleware for private endpoints (authentication required)
func (service *middleWareService) authenticated(h http.Handler) http.Handler {
h = service.mwCheckAuthentication(h)
h = mwSecureHeaders(h)
return h
}
// middleWareAuthenticate provides secure headers middleware for handlers
func (*middleWareService) middleWareSecureHeaders(next http.Handler) http.Handler {
// administrator defines a chain of middleware for private administrator restricted endpoints
// (authentication and role admin required)
func (service *middleWareService) administrator(h http.Handler) http.Handler {
h = mwCheckAdministratorRole(h)
h = service.mwCheckAuthentication(h)
h = mwSecureHeaders(h)
return h
}
// mwSecureHeaders provides secure headers middleware for handlers
func mwSecureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("X-Content-Type-Options", "nosniff")
w.Header().Add("X-Frame-Options", "DENY")
@@ -34,30 +63,57 @@ func (*middleWareService) middleWareSecureHeaders(next http.Handler) http.Handle
})
}
// middleWareAuthenticate provides Authentication middleware for handlers
func (service *middleWareService) middleWareAuthenticate(next http.Handler) http.Handler {
// mwCheckAdministratorRole check the role of the user associated to the request
func mwCheckAdministratorRole(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var token string
// Get token from the Authorization header
tokens, ok := r.Header["Authorization"]
if ok && len(tokens) >= 1 {
token = tokens[0]
token = strings.TrimPrefix(token, "Bearer ")
}
if token == "" {
Error(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil)
tokenData, err := extractTokenDataFromRequestContext(r)
if err != nil {
Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil)
return
}
err := service.jwtService.VerifyToken(token)
if err != nil {
Error(w, err, http.StatusUnauthorized, nil)
if tokenData.Role != portainer.AdministratorRole {
Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil)
return
}
next.ServeHTTP(w, r)
})
}
// mwCheckAuthentication provides Authentication middleware for handlers
func (service *middleWareService) mwCheckAuthentication(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var tokenData *portainer.TokenData
if !service.authDisabled {
var token string
// Get token from the Authorization header
tokens, ok := r.Header["Authorization"]
if ok && len(tokens) >= 1 {
token = tokens[0]
token = strings.TrimPrefix(token, "Bearer ")
}
if token == "" {
Error(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil)
return
}
var err error
tokenData, err = service.jwtService.ParseAndVerifyToken(token)
if err != nil {
Error(w, err, http.StatusUnauthorized, nil)
return
}
} else {
tokenData = &portainer.TokenData{
Role: portainer.AdministratorRole,
}
}
ctx := context.WithValue(r.Context(), contextAuthenticationKey, tokenData)
next.ServeHTTP(w, r.WithContext(ctx))
return
})
}

664
api/http/proxy_transport.go Normal file
View File

@@ -0,0 +1,664 @@
package http
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"path"
"strconv"
"strings"
"github.com/portainer/portainer"
)
type (
proxyTransport struct {
transport *http.Transport
ResourceControlService portainer.ResourceControlService
}
resourceControlMetadata struct {
OwnerID portainer.UserID `json:"OwnerId"`
}
)
func (p *proxyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
response, err := p.transport.RoundTrip(req)
if err != nil {
return response, err
}
err = p.proxyDockerRequests(req, response)
return response, err
}
func (p *proxyTransport) proxyDockerRequests(request *http.Request, response *http.Response) error {
path := request.URL.Path
if strings.HasPrefix(path, "/containers") {
return p.handleContainerRequests(request, response)
} else if strings.HasPrefix(path, "/services") {
return p.handleServiceRequests(request, response)
} else if strings.HasPrefix(path, "/volumes") {
return p.handleVolumeRequests(request, response)
}
return nil
}
func (p *proxyTransport) handleContainerRequests(request *http.Request, response *http.Response) error {
requestPath := request.URL.Path
tokenData, err := extractTokenDataFromRequestContext(request)
if err != nil {
return err
}
if requestPath == "/containers/prune" && tokenData.Role != portainer.AdministratorRole {
return writeAccessDeniedResponse(response)
}
if requestPath == "/containers/json" {
if tokenData.Role == portainer.AdministratorRole {
return p.decorateContainerResponse(response)
}
return p.proxyContainerResponseWithResourceControl(response, tokenData.ID)
}
// /containers/{id}/action
if match, _ := path.Match("/containers/*/*", requestPath); match {
if tokenData.Role != portainer.AdministratorRole {
resourceID := path.Base(path.Dir(requestPath))
return p.proxyContainerResponseWithAccessControl(response, tokenData.ID, resourceID)
}
}
return nil
}
func (p *proxyTransport) handleServiceRequests(request *http.Request, response *http.Response) error {
requestPath := request.URL.Path
tokenData, err := extractTokenDataFromRequestContext(request)
if err != nil {
return err
}
if requestPath == "/services" {
if tokenData.Role == portainer.AdministratorRole {
return p.decorateServiceResponse(response)
}
return p.proxyServiceResponseWithResourceControl(response, tokenData.ID)
}
// /services/{id}
if match, _ := path.Match("/services/*", requestPath); match {
if tokenData.Role != portainer.AdministratorRole {
resourceID := path.Base(requestPath)
return p.proxyServiceResponseWithAccessControl(response, tokenData.ID, resourceID)
}
}
// /services/{id}/action
if match, _ := path.Match("/services/*/*", requestPath); match {
if tokenData.Role != portainer.AdministratorRole {
resourceID := path.Base(path.Dir(requestPath))
return p.proxyServiceResponseWithAccessControl(response, tokenData.ID, resourceID)
}
}
return nil
}
func (p *proxyTransport) handleVolumeRequests(request *http.Request, response *http.Response) error {
requestPath := request.URL.Path
tokenData, err := extractTokenDataFromRequestContext(request)
if err != nil {
return err
}
if requestPath == "/volumes" {
if tokenData.Role == portainer.AdministratorRole {
return p.decorateVolumeResponse(response)
}
return p.proxyVolumeResponseWithResourceControl(response, tokenData.ID)
}
if requestPath == "/volumes/prune" && tokenData.Role != portainer.AdministratorRole {
return writeAccessDeniedResponse(response)
}
// /volumes/{name}
if match, _ := path.Match("/volumes/*", requestPath); match {
if tokenData.Role != portainer.AdministratorRole {
resourceID := path.Base(requestPath)
return p.proxyVolumeResponseWithAccessControl(response, tokenData.ID, resourceID)
}
}
return nil
}
func (p *proxyTransport) proxyContainerResponseWithAccessControl(response *http.Response, userID portainer.UserID, resourceID string) error {
rcs, err := p.ResourceControlService.ResourceControls(portainer.ContainerResourceControl)
if err != nil {
return err
}
userOwnedResources, err := getResourceIDsOwnedByUser(userID, rcs)
if err != nil {
return err
}
if !isStringInArray(resourceID, userOwnedResources) && isResourceIDInRCs(resourceID, rcs) {
return writeAccessDeniedResponse(response)
}
return nil
}
func (p *proxyTransport) proxyServiceResponseWithAccessControl(response *http.Response, userID portainer.UserID, resourceID string) error {
rcs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl)
if err != nil {
return err
}
userOwnedResources, err := getResourceIDsOwnedByUser(userID, rcs)
if err != nil {
return err
}
if !isStringInArray(resourceID, userOwnedResources) && isResourceIDInRCs(resourceID, rcs) {
return writeAccessDeniedResponse(response)
}
return nil
}
func (p *proxyTransport) proxyVolumeResponseWithAccessControl(response *http.Response, userID portainer.UserID, resourceID string) error {
rcs, err := p.ResourceControlService.ResourceControls(portainer.VolumeResourceControl)
if err != nil {
return err
}
userOwnedResources, err := getResourceIDsOwnedByUser(userID, rcs)
if err != nil {
return err
}
if !isStringInArray(resourceID, userOwnedResources) && isResourceIDInRCs(resourceID, rcs) {
return writeAccessDeniedResponse(response)
}
return nil
}
func (p *proxyTransport) decorateContainerResponse(response *http.Response) error {
responseData, err := getResponseData(response)
if err != nil {
return err
}
containers, err := p.decorateContainers(responseData)
if err != nil {
return err
}
err = rewriteContainerResponse(response, containers)
if err != nil {
return err
}
return nil
}
func (p *proxyTransport) proxyContainerResponseWithResourceControl(response *http.Response, userID portainer.UserID) error {
responseData, err := getResponseData(response)
if err != nil {
return err
}
containers, err := p.filterContainers(userID, responseData)
if err != nil {
return err
}
err = rewriteContainerResponse(response, containers)
if err != nil {
return err
}
return nil
}
func (p *proxyTransport) decorateServiceResponse(response *http.Response) error {
responseData, err := getResponseData(response)
if err != nil {
return err
}
services, err := p.decorateServices(responseData)
if err != nil {
return err
}
err = rewriteServiceResponse(response, services)
if err != nil {
return err
}
return nil
}
func (p *proxyTransport) proxyServiceResponseWithResourceControl(response *http.Response, userID portainer.UserID) error {
responseData, err := getResponseData(response)
if err != nil {
return err
}
volumes, err := p.filterServices(userID, responseData)
if err != nil {
return err
}
err = rewriteServiceResponse(response, volumes)
if err != nil {
return err
}
return nil
}
func (p *proxyTransport) decorateVolumeResponse(response *http.Response) error {
responseData, err := getResponseData(response)
if err != nil {
return err
}
volumes, err := p.decorateVolumes(responseData)
if err != nil {
return err
}
err = rewriteVolumeResponse(response, volumes)
if err != nil {
return err
}
return nil
}
func (p *proxyTransport) proxyVolumeResponseWithResourceControl(response *http.Response, userID portainer.UserID) error {
responseData, err := getResponseData(response)
if err != nil {
return err
}
volumes, err := p.filterVolumes(userID, responseData)
if err != nil {
return err
}
err = rewriteVolumeResponse(response, volumes)
if err != nil {
return err
}
return nil
}
func (p *proxyTransport) decorateContainers(responseData interface{}) ([]interface{}, error) {
responseDataArray := responseData.([]interface{})
containerRCs, err := p.ResourceControlService.ResourceControls(portainer.ContainerResourceControl)
if err != nil {
return nil, err
}
serviceRCs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl)
if err != nil {
return nil, err
}
decoratedResources := make([]interface{}, 0)
for _, container := range responseDataArray {
jsonObject := container.(map[string]interface{})
containerID := jsonObject["Id"].(string)
containerRC := getRCByResourceID(containerID, containerRCs)
if containerRC != nil {
decoratedObject := decorateWithResourceControlMetadata(jsonObject, containerRC.OwnerID)
decoratedResources = append(decoratedResources, decoratedObject)
continue
}
containerLabels := jsonObject["Labels"]
if containerLabels != nil {
jsonLabels := containerLabels.(map[string]interface{})
serviceID := jsonLabels["com.docker.swarm.service.id"]
if serviceID != nil {
serviceRC := getRCByResourceID(serviceID.(string), serviceRCs)
if serviceRC != nil {
decoratedObject := decorateWithResourceControlMetadata(jsonObject, serviceRC.OwnerID)
decoratedResources = append(decoratedResources, decoratedObject)
continue
}
}
}
decoratedResources = append(decoratedResources, container)
}
return decoratedResources, nil
}
func (p *proxyTransport) filterContainers(userID portainer.UserID, responseData interface{}) ([]interface{}, error) {
responseDataArray := responseData.([]interface{})
containerRCs, err := p.ResourceControlService.ResourceControls(portainer.ContainerResourceControl)
if err != nil {
return nil, err
}
serviceRCs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl)
if err != nil {
return nil, err
}
userOwnedContainerIDs, err := getResourceIDsOwnedByUser(userID, containerRCs)
if err != nil {
return nil, err
}
userOwnedServiceIDs, err := getResourceIDsOwnedByUser(userID, serviceRCs)
if err != nil {
return nil, err
}
publicContainers := getPublicContainers(responseDataArray, containerRCs, serviceRCs)
filteredResources := make([]interface{}, 0)
for _, container := range responseDataArray {
jsonObject := container.(map[string]interface{})
containerID := jsonObject["Id"].(string)
if isStringInArray(containerID, userOwnedContainerIDs) {
decoratedObject := decorateWithResourceControlMetadata(jsonObject, userID)
filteredResources = append(filteredResources, decoratedObject)
continue
}
containerLabels := jsonObject["Labels"]
if containerLabels != nil {
jsonLabels := containerLabels.(map[string]interface{})
serviceID := jsonLabels["com.docker.swarm.service.id"]
if serviceID != nil && isStringInArray(serviceID.(string), userOwnedServiceIDs) {
decoratedObject := decorateWithResourceControlMetadata(jsonObject, userID)
filteredResources = append(filteredResources, decoratedObject)
}
}
}
filteredResources = append(filteredResources, publicContainers...)
return filteredResources, nil
}
func decorateWithResourceControlMetadata(object map[string]interface{}, userID portainer.UserID) map[string]interface{} {
metadata := make(map[string]interface{})
metadata["ResourceControl"] = resourceControlMetadata{
OwnerID: userID,
}
object["Portainer"] = metadata
return object
}
func (p *proxyTransport) decorateServices(responseData interface{}) ([]interface{}, error) {
responseDataArray := responseData.([]interface{})
rcs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl)
if err != nil {
return nil, err
}
decoratedResources := make([]interface{}, 0)
for _, service := range responseDataArray {
jsonResource := service.(map[string]interface{})
resourceID := jsonResource["ID"].(string)
serviceRC := getRCByResourceID(resourceID, rcs)
if serviceRC != nil {
decoratedObject := decorateWithResourceControlMetadata(jsonResource, serviceRC.OwnerID)
decoratedResources = append(decoratedResources, decoratedObject)
continue
}
decoratedResources = append(decoratedResources, service)
}
return decoratedResources, nil
}
func (p *proxyTransport) filterServices(userID portainer.UserID, responseData interface{}) ([]interface{}, error) {
responseDataArray := responseData.([]interface{})
rcs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl)
if err != nil {
return nil, err
}
userOwnedServiceIDs, err := getResourceIDsOwnedByUser(userID, rcs)
if err != nil {
return nil, err
}
publicServices := getPublicResources(responseDataArray, rcs, "ID")
filteredResources := make([]interface{}, 0)
for _, res := range responseDataArray {
jsonResource := res.(map[string]interface{})
resourceID := jsonResource["ID"].(string)
if isStringInArray(resourceID, userOwnedServiceIDs) {
decoratedObject := decorateWithResourceControlMetadata(jsonResource, userID)
filteredResources = append(filteredResources, decoratedObject)
}
}
filteredResources = append(filteredResources, publicServices...)
return filteredResources, nil
}
func (p *proxyTransport) decorateVolumes(responseData interface{}) ([]interface{}, error) {
var responseDataArray []interface{}
jsonObject := responseData.(map[string]interface{})
if jsonObject["Volumes"] != nil {
responseDataArray = jsonObject["Volumes"].([]interface{})
}
rcs, err := p.ResourceControlService.ResourceControls(portainer.VolumeResourceControl)
if err != nil {
return nil, err
}
decoratedResources := make([]interface{}, 0)
for _, volume := range responseDataArray {
jsonResource := volume.(map[string]interface{})
resourceID := jsonResource["Name"].(string)
volumeRC := getRCByResourceID(resourceID, rcs)
if volumeRC != nil {
decoratedObject := decorateWithResourceControlMetadata(jsonResource, volumeRC.OwnerID)
decoratedResources = append(decoratedResources, decoratedObject)
continue
}
decoratedResources = append(decoratedResources, volume)
}
return decoratedResources, nil
}
func (p *proxyTransport) filterVolumes(userID portainer.UserID, responseData interface{}) ([]interface{}, error) {
var responseDataArray []interface{}
jsonObject := responseData.(map[string]interface{})
if jsonObject["Volumes"] != nil {
responseDataArray = jsonObject["Volumes"].([]interface{})
}
rcs, err := p.ResourceControlService.ResourceControls(portainer.VolumeResourceControl)
if err != nil {
return nil, err
}
userOwnedVolumeIDs, err := getResourceIDsOwnedByUser(userID, rcs)
if err != nil {
return nil, err
}
publicVolumes := getPublicResources(responseDataArray, rcs, "Name")
filteredResources := make([]interface{}, 0)
for _, res := range responseDataArray {
jsonResource := res.(map[string]interface{})
resourceID := jsonResource["Name"].(string)
if isStringInArray(resourceID, userOwnedVolumeIDs) {
decoratedObject := decorateWithResourceControlMetadata(jsonResource, userID)
filteredResources = append(filteredResources, decoratedObject)
}
}
filteredResources = append(filteredResources, publicVolumes...)
return filteredResources, nil
}
func getResourceIDsOwnedByUser(userID portainer.UserID, rcs []portainer.ResourceControl) ([]string, error) {
ownedResources := make([]string, 0)
for _, rc := range rcs {
if rc.OwnerID == userID {
ownedResources = append(ownedResources, rc.ResourceID)
}
}
return ownedResources, nil
}
func getOwnedServiceContainers(responseData []interface{}, serviceRCs []portainer.ResourceControl) []interface{} {
ownedContainers := make([]interface{}, 0)
for _, res := range responseData {
jsonResource := res.(map[string]map[string]interface{})
swarmServiceID := jsonResource["Labels"]["com.docker.swarm.service.id"]
if swarmServiceID != nil {
resourceID := swarmServiceID.(string)
if isResourceIDInRCs(resourceID, serviceRCs) {
ownedContainers = append(ownedContainers, res)
}
}
}
return ownedContainers
}
func getPublicContainers(responseData []interface{}, containerRCs []portainer.ResourceControl, serviceRCs []portainer.ResourceControl) []interface{} {
publicContainers := make([]interface{}, 0)
for _, container := range responseData {
jsonObject := container.(map[string]interface{})
containerID := jsonObject["Id"].(string)
if !isResourceIDInRCs(containerID, containerRCs) {
containerLabels := jsonObject["Labels"]
if containerLabels != nil {
jsonLabels := containerLabels.(map[string]interface{})
serviceID := jsonLabels["com.docker.swarm.service.id"]
if serviceID == nil {
publicContainers = append(publicContainers, container)
} else if serviceID != nil && !isResourceIDInRCs(serviceID.(string), serviceRCs) {
publicContainers = append(publicContainers, container)
}
} else {
publicContainers = append(publicContainers, container)
}
}
}
return publicContainers
}
func getPublicResources(responseData []interface{}, rcs []portainer.ResourceControl, resourceIDKey string) []interface{} {
publicResources := make([]interface{}, 0)
for _, res := range responseData {
jsonResource := res.(map[string]interface{})
resourceID := jsonResource[resourceIDKey].(string)
if !isResourceIDInRCs(resourceID, rcs) {
publicResources = append(publicResources, res)
}
}
return publicResources
}
func isStringInArray(target string, array []string) bool {
for _, element := range array {
if element == target {
return true
}
}
return false
}
func isResourceIDInRCs(resourceID string, rcs []portainer.ResourceControl) bool {
for _, rc := range rcs {
if resourceID == rc.ResourceID {
return true
}
}
return false
}
func getRCByResourceID(resourceID string, rcs []portainer.ResourceControl) *portainer.ResourceControl {
for _, rc := range rcs {
if resourceID == rc.ResourceID {
return &rc
}
}
return nil
}
func getResponseData(response *http.Response) (interface{}, error) {
var data interface{}
if response.Body != nil {
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return nil, err
}
err = response.Body.Close()
if err != nil {
return nil, err
}
err = json.Unmarshal(body, &data)
if err != nil {
return nil, err
}
return data, nil
}
return nil, ErrEmptyResponseBody
}
func writeAccessDeniedResponse(response *http.Response) error {
return rewriteResponse(response, portainer.ErrResourceAccessDenied, 403)
}
func rewriteContainerResponse(response *http.Response, responseData interface{}) error {
return rewriteResponse(response, responseData, 200)
}
func rewriteServiceResponse(response *http.Response, responseData interface{}) error {
return rewriteResponse(response, responseData, 200)
}
func rewriteVolumeResponse(response *http.Response, responseData interface{}) error {
data := map[string]interface{}{}
data["Volumes"] = responseData
return rewriteResponse(response, data, 200)
}
func rewriteResponse(response *http.Response, newContent interface{}, statusCode int) error {
jsonData, err := json.Marshal(newContent)
if err != nil {
return err
}
body := ioutil.NopCloser(bytes.NewReader(jsonData))
response.StatusCode = statusCode
response.Body = body
response.ContentLength = int64(len(jsonData))
response.Header.Set("Content-Length", strconv.Itoa(len(jsonData)))
return nil
}

View File

@@ -8,59 +8,49 @@ import (
// Server implements the portainer.Server interface
type Server struct {
BindAddress string
AssetsPath string
UserService portainer.UserService
EndpointService portainer.EndpointService
CryptoService portainer.CryptoService
JWTService portainer.JWTService
FileService portainer.FileService
Settings *portainer.Settings
TemplatesURL string
ActiveEndpoint *portainer.Endpoint
Handler *Handler
}
func (server *Server) updateActiveEndpoint(endpoint *portainer.Endpoint) error {
if endpoint != nil {
server.ActiveEndpoint = endpoint
server.Handler.WebSocketHandler.endpoint = endpoint
err := server.Handler.DockerHandler.setupProxy(endpoint)
if err != nil {
return err
}
err = server.EndpointService.SetActive(endpoint)
if err != nil {
return err
}
}
return nil
BindAddress string
AssetsPath string
AuthDisabled bool
EndpointManagement bool
UserService portainer.UserService
EndpointService portainer.EndpointService
ResourceControlService portainer.ResourceControlService
CryptoService portainer.CryptoService
JWTService portainer.JWTService
FileService portainer.FileService
Settings *portainer.Settings
TemplatesURL string
Handler *Handler
}
// Start starts the HTTP server
func (server *Server) Start() error {
middleWareService := &middleWareService{
jwtService: server.JWTService,
jwtService: server.JWTService,
authDisabled: server.AuthDisabled,
}
var authHandler = NewAuthHandler()
var authHandler = NewAuthHandler(middleWareService)
authHandler.UserService = server.UserService
authHandler.CryptoService = server.CryptoService
authHandler.JWTService = server.JWTService
authHandler.authDisabled = server.AuthDisabled
var userHandler = NewUserHandler(middleWareService)
userHandler.UserService = server.UserService
userHandler.CryptoService = server.CryptoService
userHandler.ResourceControlService = server.ResourceControlService
var settingsHandler = NewSettingsHandler(middleWareService)
settingsHandler.settings = server.Settings
var templatesHandler = NewTemplatesHandler(middleWareService)
templatesHandler.templatesURL = server.TemplatesURL
var dockerHandler = NewDockerHandler(middleWareService)
var dockerHandler = NewDockerHandler(middleWareService, server.ResourceControlService)
dockerHandler.EndpointService = server.EndpointService
var websocketHandler = NewWebSocketHandler()
// EndpointHandler requires a reference to the server to be able to update the active endpoint.
websocketHandler.EndpointService = server.EndpointService
var endpointHandler = NewEndpointHandler(middleWareService)
endpointHandler.authorizeEndpointManagement = server.EndpointManagement
endpointHandler.EndpointService = server.EndpointService
endpointHandler.FileService = server.FileService
endpointHandler.server = server
var uploadHandler = NewUploadHandler(middleWareService)
uploadHandler.FileService = server.FileService
var fileHandler = newFileHandler(server.AssetsPath)
@@ -76,10 +66,6 @@ func (server *Server) Start() error {
FileHandler: fileHandler,
UploadHandler: uploadHandler,
}
err := server.updateActiveEndpoint(server.ActiveEndpoint)
if err != nil {
return err
}
return http.ListenAndServe(server.BindAddress, server.Handler)
}

View File

@@ -13,19 +13,19 @@ import (
// SettingsHandler represents an HTTP API handler for managing settings.
type SettingsHandler struct {
*mux.Router
Logger *log.Logger
middleWareService *middleWareService
settings *portainer.Settings
Logger *log.Logger
settings *portainer.Settings
}
// NewSettingsHandler returns a new instance of SettingsHandler.
func NewSettingsHandler(middleWareService *middleWareService) *SettingsHandler {
func NewSettingsHandler(mw *middleWareService) *SettingsHandler {
h := &SettingsHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
middleWareService: middleWareService,
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.HandleFunc("/settings", h.handleGetSettings)
h.Handle("/settings",
mw.public(http.HandlerFunc(h.handleGetSettings)))
return h
}

View File

@@ -12,21 +12,18 @@ import (
// TemplatesHandler represents an HTTP API handler for managing templates.
type TemplatesHandler struct {
*mux.Router
Logger *log.Logger
middleWareService *middleWareService
templatesURL string
Logger *log.Logger
templatesURL string
}
// NewTemplatesHandler returns a new instance of TemplatesHandler.
func NewTemplatesHandler(middleWareService *middleWareService) *TemplatesHandler {
func NewTemplatesHandler(mw *middleWareService) *TemplatesHandler {
h := &TemplatesHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
middleWareService: middleWareService,
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/templates", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.handleGetTemplates(w, r)
})))
h.Handle("/templates",
mw.authenticated(http.HandlerFunc(h.handleGetTemplates)))
return h
}

View File

@@ -14,21 +14,18 @@ import (
// UploadHandler represents an HTTP API handler for managing file uploads.
type UploadHandler struct {
*mux.Router
Logger *log.Logger
FileService portainer.FileService
middleWareService *middleWareService
Logger *log.Logger
FileService portainer.FileService
}
// NewUploadHandler returns a new instance of UploadHandler.
func NewUploadHandler(middleWareService *middleWareService) *UploadHandler {
func NewUploadHandler(mw *middleWareService) *UploadHandler {
h := &UploadHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
middleWareService: middleWareService,
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/upload/tls/{endpointID}/{certificate:(?:ca|cert|key)}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.handlePostUploadTLS(w, r)
})))
h.Handle("/upload/tls/{endpointID}/{certificate:(?:ca|cert|key)}",
mw.authenticated(http.HandlerFunc(h.handlePostUploadTLS)))
return h
}

View File

@@ -1,6 +1,8 @@
package http
import (
"strconv"
"github.com/portainer/portainer"
"encoding/json"
@@ -15,43 +17,44 @@ import (
// UserHandler represents an HTTP API handler for managing users.
type UserHandler struct {
*mux.Router
Logger *log.Logger
UserService portainer.UserService
CryptoService portainer.CryptoService
middleWareService *middleWareService
Logger *log.Logger
UserService portainer.UserService
ResourceControlService portainer.ResourceControlService
CryptoService portainer.CryptoService
}
// NewUserHandler returns a new instance of UserHandler.
func NewUserHandler(middleWareService *middleWareService) *UserHandler {
func NewUserHandler(mw *middleWareService) *UserHandler {
h := &UserHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
middleWareService: middleWareService,
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/users", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.handlePostUsers(w, r)
})))
h.Handle("/users/{username}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.handleGetUser(w, r)
}))).Methods(http.MethodGet)
h.Handle("/users/{username}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.handlePutUser(w, r)
}))).Methods(http.MethodPut)
h.Handle("/users/{username}/passwd", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.handlePostUserPasswd(w, r)
})))
h.HandleFunc("/users/admin/check", h.handleGetAdminCheck)
h.HandleFunc("/users/admin/init", h.handlePostAdminInit)
h.Handle("/users",
mw.administrator(http.HandlerFunc(h.handlePostUsers))).Methods(http.MethodPost)
h.Handle("/users",
mw.administrator(http.HandlerFunc(h.handleGetUsers))).Methods(http.MethodGet)
h.Handle("/users/{id}",
mw.administrator(http.HandlerFunc(h.handleGetUser))).Methods(http.MethodGet)
h.Handle("/users/{id}",
mw.authenticated(http.HandlerFunc(h.handlePutUser))).Methods(http.MethodPut)
h.Handle("/users/{id}",
mw.administrator(http.HandlerFunc(h.handleDeleteUser))).Methods(http.MethodDelete)
h.Handle("/users/{id}/passwd",
mw.authenticated(http.HandlerFunc(h.handlePostUserPasswd)))
h.Handle("/users/{userId}/resources/{resourceType}",
mw.authenticated(http.HandlerFunc(h.handlePostUserResource))).Methods(http.MethodPost)
h.Handle("/users/{userId}/resources/{resourceType}/{resourceId}",
mw.authenticated(http.HandlerFunc(h.handleDeleteUserResource))).Methods(http.MethodDelete)
h.Handle("/users/admin/check",
mw.public(http.HandlerFunc(h.handleGetAdminCheck)))
h.Handle("/users/admin/init",
mw.public(http.HandlerFunc(h.handlePostAdminInit)))
return h
}
// handlePostUsers handles POST requests on /users
func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
handleNotAllowed(w, []string{http.MethodPost})
return
}
var req postUsersRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
@@ -64,8 +67,26 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque
return
}
user := &portainer.User{
var role portainer.UserRole
if req.Role == 1 {
role = portainer.AdministratorRole
} else {
role = portainer.StandardUserRole
}
user, err := handler.UserService.UserByUsername(req.Username)
if err != nil && err != portainer.ErrUserNotFound {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if user != nil {
Error(w, portainer.ErrUserAlreadyExists, http.StatusConflict, handler.Logger)
return
}
user = &portainer.User{
Username: req.Username,
Role: role,
}
user.Password, err = handler.CryptoService.Hash(req.Password)
if err != nil {
@@ -73,7 +94,7 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque
return
}
err = handler.UserService.UpdateUser(user)
err = handler.UserService.CreateUser(user)
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
@@ -83,9 +104,24 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque
type postUsersRequest struct {
Username string `valid:"alphanum,required"`
Password string `valid:"required"`
Role int `valid:"required"`
}
// handlePostUserPasswd handles POST requests on /users/:username/passwd
// handleGetUsers handles GET requests on /users
func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) {
users, err := handler.UserService.Users()
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
for i := range users {
users[i].Password = ""
}
encodeJSON(w, users, handler.Logger)
}
// handlePostUserPasswd handles POST requests on /users/:id/passwd
func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
handleNotAllowed(w, []string{http.MethodPost})
@@ -93,15 +129,21 @@ func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.
}
vars := mux.Vars(r)
username := vars["username"]
id := vars["id"]
userID, err := strconv.Atoi(id)
if err != nil {
Error(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req postUserPasswdRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
_, err = govalidator.ValidateStruct(req)
if err != nil {
Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
@@ -109,7 +151,7 @@ func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.
var password = req.Password
u, err := handler.UserService.User(username)
u, err := handler.UserService.User(portainer.UserID(userID))
if err == portainer.ErrUserNotFound {
Error(w, err, http.StatusNotFound, handler.Logger)
return
@@ -135,12 +177,18 @@ type postUserPasswdResponse struct {
Valid bool `json:"valid"`
}
// handleGetUser handles GET requests on /users/:username
// handleGetUser handles GET requests on /users/:id
func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
username := vars["username"]
id := vars["id"]
user, err := handler.UserService.User(username)
userID, err := strconv.Atoi(id)
if err != nil {
Error(w, err, http.StatusBadRequest, handler.Logger)
return
}
user, err := handler.UserService.User(portainer.UserID(userID))
if err == portainer.ErrUserNotFound {
Error(w, err, http.StatusNotFound, handler.Logger)
return
@@ -153,30 +201,74 @@ func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request
encodeJSON(w, &user, handler.Logger)
}
// handlePutUser handles PUT requests on /users/:username
// handlePutUser handles PUT requests on /users/:id
func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
userID, err := strconv.Atoi(id)
if err != nil {
Error(w, err, http.StatusBadRequest, handler.Logger)
return
}
tokenData, err := extractTokenDataFromRequestContext(r)
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
}
if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) {
Error(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger)
return
}
var req putUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
_, err = govalidator.ValidateStruct(req)
if err != nil {
Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
user := &portainer.User{
Username: req.Username,
}
user.Password, err = handler.CryptoService.Hash(req.Password)
if err != nil {
Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger)
if req.Password == "" && req.Role == 0 {
Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
err = handler.UserService.UpdateUser(user)
user, err := handler.UserService.User(portainer.UserID(userID))
if err == portainer.ErrUserNotFound {
Error(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if req.Password != "" {
user.Password, err = handler.CryptoService.Hash(req.Password)
if err != nil {
Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger)
return
}
}
if req.Role != 0 {
if tokenData.Role != portainer.AdministratorRole {
Error(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger)
return
}
if req.Role == 1 {
user.Role = portainer.AdministratorRole
} else {
user.Role = portainer.StandardUserRole
}
}
err = handler.UserService.UpdateUser(user.ID, user)
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
@@ -184,8 +276,8 @@ func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request
}
type putUserRequest struct {
Username string `valid:"alphanum,required"`
Password string `valid:"required"`
Password string `valid:"-"`
Role int `valid:"-"`
}
// handlePostAdminInit handles GET requests on /users/admin/check
@@ -195,17 +287,15 @@ func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.R
return
}
user, err := handler.UserService.User("admin")
if err == portainer.ErrUserNotFound {
Error(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
users, err := handler.UserService.UsersByRole(portainer.AdministratorRole)
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
user.Password = ""
encodeJSON(w, &user, handler.Logger)
if len(users) == 0 {
Error(w, portainer.ErrUserNotFound, http.StatusNotFound, handler.Logger)
return
}
}
// handlePostAdminInit handles POST requests on /users/admin/init
@@ -227,10 +317,11 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R
return
}
user, err := handler.UserService.User("admin")
user, err := handler.UserService.UserByUsername("admin")
if err == portainer.ErrUserNotFound {
user := &portainer.User{
Username: "admin",
Role: portainer.AdministratorRole,
}
user.Password, err = handler.CryptoService.Hash(req.Password)
if err != nil {
@@ -238,7 +329,7 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R
return
}
err = handler.UserService.UpdateUser(user)
err = handler.UserService.CreateUser(user)
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
@@ -256,3 +347,134 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R
type postAdminInitRequest struct {
Password string `valid:"required"`
}
// handleDeleteUser handles DELETE requests on /users/:id
func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
userID, err := strconv.Atoi(id)
if err != nil {
Error(w, err, http.StatusBadRequest, handler.Logger)
return
}
_, err = handler.UserService.User(portainer.UserID(userID))
if err == portainer.ErrUserNotFound {
Error(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.UserService.DeleteUser(portainer.UserID(userID))
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handlePostUserResource handles POST requests on /users/:userId/resources/:resourceType
func (handler *UserHandler) handlePostUserResource(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
userID := vars["userId"]
resourceType := vars["resourceType"]
uid, err := strconv.Atoi(userID)
if err != nil {
Error(w, err, http.StatusBadRequest, handler.Logger)
return
}
var rcType portainer.ResourceControlType
if resourceType == "container" {
rcType = portainer.ContainerResourceControl
} else if resourceType == "service" {
rcType = portainer.ServiceResourceControl
} else if resourceType == "volume" {
rcType = portainer.VolumeResourceControl
} else {
Error(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
return
}
tokenData, err := extractTokenDataFromRequestContext(r)
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
}
if tokenData.ID != portainer.UserID(uid) {
Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
var req postUserResourceRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
resource := portainer.ResourceControl{
OwnerID: portainer.UserID(uid),
ResourceID: req.ResourceID,
AccessLevel: portainer.RestrictedResourceAccessLevel,
}
err = handler.ResourceControlService.CreateResourceControl(req.ResourceID, &resource, rcType)
if err != nil {
Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
}
type postUserResourceRequest struct {
ResourceID string `valid:"required"`
}
// handleDeleteUserResource handles DELETE requests on /users/:userId/resources/:resourceType/:resourceId
func (handler *UserHandler) handleDeleteUserResource(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
userID := vars["userId"]
resourceID := vars["resourceId"]
resourceType := vars["resourceType"]
uid, err := strconv.Atoi(userID)
if err != nil {
Error(w, err, http.StatusBadRequest, handler.Logger)
return
}
var rcType portainer.ResourceControlType
if resourceType == "container" {
rcType = portainer.ContainerResourceControl
} else if resourceType == "service" {
rcType = portainer.ServiceResourceControl
} else if resourceType == "volume" {
rcType = portainer.VolumeResourceControl
} else {
Error(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
return
}
tokenData, err := extractTokenDataFromRequestContext(r)
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
}
if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(uid) {
Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
err = handler.ResourceControlService.DeleteResourceControl(resourceID, rcType)
if err != nil {
Error(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}

View File

@@ -1,8 +1,6 @@
package http
import (
"github.com/portainer/portainer"
"bytes"
"crypto/tls"
"encoding/json"
@@ -14,18 +12,19 @@ import (
"net/http/httputil"
"net/url"
"os"
"strconv"
"time"
"github.com/gorilla/mux"
"github.com/portainer/portainer"
"golang.org/x/net/websocket"
)
// WebSocketHandler represents an HTTP API handler for proxying requests to a web socket.
type WebSocketHandler struct {
*mux.Router
Logger *log.Logger
middleWareService *middleWareService
endpoint *portainer.Endpoint
Logger *log.Logger
EndpointService portainer.EndpointService
}
// NewWebSocketHandler returns a new instance of WebSocketHandler.
@@ -41,34 +40,47 @@ func NewWebSocketHandler() *WebSocketHandler {
func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) {
qry := ws.Request().URL.Query()
execID := qry.Get("id")
edpID := qry.Get("endpointId")
// Should not be managed here
endpoint, err := url.Parse(handler.endpoint.URL)
parsedID, err := strconv.Atoi(edpID)
if err != nil {
log.Fatalf("Unable to parse endpoint URL: %s", err)
log.Printf("Unable to parse endpoint ID: %s", err)
return
}
endpointID := portainer.EndpointID(parsedID)
endpoint, err := handler.EndpointService.Endpoint(endpointID)
if err != nil {
log.Printf("Unable to retrieve endpoint: %s", err)
return
}
endpointURL, err := url.Parse(endpoint.URL)
if err != nil {
log.Printf("Unable to parse endpoint URL: %s", err)
return
}
var host string
if endpoint.Scheme == "tcp" {
host = endpoint.Host
} else if endpoint.Scheme == "unix" {
host = endpoint.Path
if endpointURL.Scheme == "tcp" {
host = endpointURL.Host
} else if endpointURL.Scheme == "unix" {
host = endpointURL.Path
}
// Should not be managed here
var tlsConfig *tls.Config
if handler.endpoint.TLS {
tlsConfig, err = createTLSConfiguration(handler.endpoint.TLSCACertPath,
handler.endpoint.TLSCertPath,
handler.endpoint.TLSKeyPath)
if endpoint.TLS {
tlsConfig, err = createTLSConfiguration(endpoint.TLSCACertPath,
endpoint.TLSCertPath,
endpoint.TLSKeyPath)
if err != nil {
log.Fatalf("Unable to create TLS configuration: %s", err)
return
}
}
if err := hijack(host, endpoint.Scheme, "POST", "/exec/"+execID+"/start", tlsConfig, true, ws, ws, ws, nil, nil); err != nil {
if err := hijack(host, endpointURL.Scheme, "POST", "/exec/"+execID+"/start", tlsConfig, true, ws, ws, ws, nil, nil); err != nil {
log.Fatalf("error during hijack: %s", err)
return
}

View File

@@ -4,9 +4,10 @@ import (
"github.com/portainer/portainer"
"fmt"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/gorilla/securecookie"
"time"
)
// Service represents a service for managing JWT tokens.
@@ -15,7 +16,9 @@ type Service struct {
}
type claims struct {
UserID int `json:"id"`
Username string `json:"username"`
Role int `json:"role"`
jwt.StandardClaims
}
@@ -35,7 +38,9 @@ func NewService() (*Service, error) {
func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) {
expireToken := time.Now().Add(time.Hour * 8).Unix()
cl := claims{
int(data.ID),
data.Username,
int(data.Role),
jwt.StandardClaims{
ExpiresAt: expireToken,
},
@@ -50,17 +55,25 @@ func (service *Service) GenerateToken(data *portainer.TokenData) (string, error)
return signedToken, nil
}
// VerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid.
func (service *Service) VerifyToken(token string) error {
parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) {
// ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid.
func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData, error) {
parsedToken, err := jwt.ParseWithClaims(token, &claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
msg := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
return nil, msg
}
return service.secret, nil
})
if err != nil || parsedToken == nil || !parsedToken.Valid {
return portainer.ErrInvalidJWTToken
if err == nil && parsedToken != nil {
if cl, ok := parsedToken.Claims.(*claims); ok && parsedToken.Valid {
tokenData := &portainer.TokenData{
ID: portainer.UserID(cl.UserID),
Username: cl.Username,
Role: portainer.UserRole(cl.Role),
}
return tokenData, nil
}
}
return nil
return nil, portainer.ErrInvalidJWTToken
}

View File

@@ -13,34 +13,52 @@ type (
// CLIFlags represents the available flags on the CLI.
CLIFlags struct {
Addr *string
Assets *string
Data *string
Endpoint *string
Labels *[]Pair
Logo *string
Templates *string
TLSVerify *bool
TLSCacert *string
TLSCert *string
TLSKey *string
Addr *string
Assets *string
Data *string
ExternalEndpoints *string
SyncInterval *string
Endpoint *string
Labels *[]Pair
Logo *string
Templates *string
NoAuth *bool
NoAnalytics *bool
TLSVerify *bool
TLSCacert *string
TLSCert *string
TLSKey *string
}
// Settings represents Portainer settings.
Settings struct {
HiddenLabels []Pair `json:"hiddenLabels"`
Logo string `json:"logo"`
HiddenLabels []Pair `json:"hiddenLabels"`
Logo string `json:"logo"`
Authentication bool `json:"authentication"`
Analytics bool `json:"analytics"`
EndpointManagement bool `json:"endpointManagement"`
}
// User represent a user account.
User struct {
Username string `json:"Username"`
Password string `json:"Password,omitempty"`
ID UserID `json:"Id"`
Username string `json:"Username"`
Password string `json:"Password,omitempty"`
Role UserRole `json:"Role"`
}
// UserID represents a user identifier
UserID int
// UserRole represents the role of a user. It can be either an administrator
// or a regular user.
UserRole int
// TokenData represents the data embedded in a JWT token.
TokenData struct {
ID UserID
Username string
Role UserRole
}
// EndpointID represents an endpoint identifier.
@@ -49,15 +67,31 @@ type (
// Endpoint represents a Docker endpoint with all the info required
// to connect to it.
Endpoint struct {
ID EndpointID `json:"Id"`
Name string `json:"Name"`
URL string `json:"URL"`
TLS bool `json:"TLS"`
TLSCACertPath string `json:"TLSCACert,omitempty"`
TLSCertPath string `json:"TLSCert,omitempty"`
TLSKeyPath string `json:"TLSKey,omitempty"`
ID EndpointID `json:"Id"`
Name string `json:"Name"`
URL string `json:"URL"`
TLS bool `json:"TLS"`
TLSCACertPath string `json:"TLSCACert,omitempty"`
TLSCertPath string `json:"TLSCert,omitempty"`
TLSKeyPath string `json:"TLSKey,omitempty"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
}
// ResourceControl represent a reference to a Docker resource with specific controls
ResourceControl struct {
OwnerID UserID `json:"OwnerId"`
ResourceID string `json:"ResourceId"`
AccessLevel ResourceAccessLevel `json:"AccessLevel"`
}
// ResourceControlType represents a type of resource control.
// Can be one of: container, service or volume.
ResourceControlType int
// ResourceAccessLevel represents the level of control associated to a resource for a specific owner.
// Can be one of: full, restricted, limited.
ResourceAccessLevel int
// TLSFileType represents a type of TLS file required to connect to a Docker endpoint.
// It can be either a TLS CA file, a TLS certificate file or a TLS key file.
TLSFileType int
@@ -72,29 +106,47 @@ type (
DataStore interface {
Open() error
Close() error
MigrateData() error
}
// Server defines the interface to serve the data.
// Server defines the interface to serve the API.
Server interface {
Start() error
}
// UserService represents a service for managing users.
// UserService represents a service for managing user data.
UserService interface {
User(username string) (*User, error)
UpdateUser(user *User) error
User(ID UserID) (*User, error)
UserByUsername(username string) (*User, error)
Users() ([]User, error)
UsersByRole(role UserRole) ([]User, error)
CreateUser(user *User) error
UpdateUser(ID UserID, user *User) error
DeleteUser(ID UserID) error
}
// EndpointService represents a service for managing endpoints.
// EndpointService represents a service for managing endpoint data.
EndpointService interface {
Endpoint(ID EndpointID) (*Endpoint, error)
Endpoints() ([]Endpoint, error)
CreateEndpoint(endpoint *Endpoint) error
UpdateEndpoint(ID EndpointID, endpoint *Endpoint) error
DeleteEndpoint(ID EndpointID) error
GetActive() (*Endpoint, error)
SetActive(endpoint *Endpoint) error
DeleteActive() error
Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error
}
// VersionService represents a service for managing version data.
VersionService interface {
DBVersion() (int, error)
StoreDBVersion(version int) error
}
// ResourceControlService represents a service for managing resource control data.
ResourceControlService interface {
ResourceControl(resourceID string, rcType ResourceControlType) (*ResourceControl, error)
ResourceControls(rcType ResourceControlType) ([]ResourceControl, error)
CreateResourceControl(resourceID string, rc *ResourceControl, rcType ResourceControlType) error
DeleteResourceControl(resourceID string, rcType ResourceControlType) error
}
// CryptoService represents a service for encrypting/hashing data.
@@ -106,7 +158,7 @@ type (
// JWTService represents a service for managing JWT tokens.
JWTService interface {
GenerateToken(data *TokenData) (string, error)
VerifyToken(token string) error
ParseAndVerifyToken(token string) (*TokenData, error)
}
// FileService represents a service for managing files.
@@ -115,11 +167,18 @@ type (
GetPathForTLSFile(endpointID EndpointID, fileType TLSFileType) (string, error)
DeleteTLSFiles(endpointID EndpointID) error
}
// EndpointWatcher represents a service to synchronize the endpoints via an external source.
EndpointWatcher interface {
WatchEndpointFile(endpointFilePath string) error
}
)
const (
// APIVersion is the version number of portainer API.
APIVersion = "1.11.2"
// APIVersion is the version number of Portainer API.
APIVersion = "1.12.2"
// DBVersion is the version number of Portainer database.
DBVersion = 1
)
const (
@@ -130,3 +189,27 @@ const (
// TLSFileKey represents a TLS key file.
TLSFileKey
)
const (
_ UserRole = iota
// AdministratorRole represents an administrator user role
AdministratorRole
// StandardUserRole represents a regular user role
StandardUserRole
)
const (
_ ResourceControlType = iota
// ContainerResourceControl represents a resource control for a container
ContainerResourceControl
// ServiceResourceControl represents a resource control for a service
ServiceResourceControl
// VolumeResourceControl represents a resource control for a volume
VolumeResourceControl
)
const (
_ ResourceAccessLevel = iota
// RestrictedResourceAccessLevel represents a restricted access level on a resource (private ownership)
RestrictedResourceAccessLevel
)

1133
app/app.js

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
angular.module('auth', [])
.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Config', 'Authentication', 'Users', 'EndpointService', 'StateManager', 'Messages',
function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Authentication, Users, EndpointService, StateManager, Messages) {
.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Config', 'Authentication', 'Users', 'EndpointService', 'StateManager', 'EndpointProvider', 'Messages',
function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Authentication, Users, EndpointService, StateManager, EndpointProvider, Messages) {
$scope.authData = {
username: 'admin',
@@ -13,6 +13,39 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au
error: false
};
if (!$scope.applicationState.application.authentication) {
EndpointService.endpoints()
.then(function success(data) {
if (data.length > 0) {
endpointID = EndpointProvider.endpointID();
if (!endpointID) {
endpointID = data[0].Id;
EndpointProvider.setEndpointID(endpointID);
}
StateManager.updateEndpointState(true)
.then(function success() {
$state.go('dashboard');
}, function error(err) {
Messages.error("Failure", err, 'Unable to connect to the Docker endpoint');
});
}
else {
$state.go('endpointInit');
}
}, function error(err) {
Messages.error("Failure", err, 'Unable to retrieve endpoints');
});
} else {
Users.checkAdminUser({}, function () {},
function (e) {
if (e.status === 404) {
$scope.initPassword = true;
} else {
Messages.error("Failure", e, 'Unable to verify administrator account existence');
}
});
}
if ($stateParams.logout) {
Authentication.logout();
}
@@ -30,15 +63,6 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au
$scope.logo = c.logo;
});
Users.checkAdminUser({}, function (d) {},
function (e) {
if (e.status === 404) {
$scope.initPassword = true;
} else {
Messages.error("Failure", e, 'Unable to verify administrator account existence');
}
});
$scope.createAdminUser = function() {
var password = $sanitize($scope.initPasswordData.password);
Users.initAdminUser({password: password}, function (d) {
@@ -58,23 +82,34 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au
$scope.authenticationError = false;
var username = $sanitize($scope.authData.username);
var password = $sanitize($scope.authData.password);
Authentication.login(username, password).then(function success() {
EndpointService.getActive().then(function success(data) {
Authentication.login(username, password)
.then(function success(data) {
return EndpointService.endpoints();
})
.then(function success(data) {
var userDetails = Authentication.getUserDetails();
if (data.length > 0) {
endpointID = EndpointProvider.endpointID();
if (!endpointID) {
endpointID = data[0].Id;
EndpointProvider.setEndpointID(endpointID);
}
StateManager.updateEndpointState(true)
.then(function success() {
$state.go('dashboard');
}, function error(err) {
Messages.error("Failure", err, 'Unable to connect to the Docker endpoint');
});
}, function error(err) {
if (err.status === 404) {
$state.go('endpointInit');
} else {
Messages.error("Failure", err, 'Unable to verify Docker endpoint existence');
}
});
}, function error() {
$scope.authData.error = 'Invalid credentials';
}
else if (data.length === 0 && userDetails.role === 1) {
$state.go('endpointInit');
} else if (data.length === 0 && userDetails.role === 2) {
Authentication.logout();
$scope.authData.error = 'User not allowed. Please contact your administrator.';
}
})
.catch(function error(err) {
$scope.authData.error = 'Authentication error';
});
};
}]);

View File

@@ -13,12 +13,12 @@
<rd-widget-header icon="fa-cogs" title="Actions"></rd-widget-header>
<rd-widget-body classes="padding">
<div class="btn-group" role="group" aria-label="...">
<button class="btn btn-primary" ng-click="start()" ng-if="!container.State.Running"><i class="fa fa-play space-right" aria-hidden="true"></i>Start</button>
<button class="btn btn-danger" ng-click="stop()" ng-if="container.State.Running"><i class="fa fa-stop space-right" aria-hidden="true"></i>Stop</button>
<button class="btn btn-danger" ng-click="kill()" ng-if="container.State.Running"><i class="fa fa-bomb space-right" aria-hidden="true"></i>Kill</button>
<button class="btn btn-primary" ng-click="restart()" ng-if="container.State.Running"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Restart</button>
<button class="btn btn-primary" ng-click="pause()" ng-if="container.State.Running && !container.State.Paused"><i class="fa fa-pause space-right" aria-hidden="true"></i>Pause</button>
<button class="btn btn-primary" ng-click="unpause()" ng-if="container.State.Paused"><i class="fa fa-play space-right" aria-hidden="true"></i>Resume</button>
<button class="btn btn-success" ng-click="start()" ng-disabled="container.State.Running"><i class="fa fa-play space-right" aria-hidden="true"></i>Start</button>
<button class="btn btn-danger" ng-click="stop()" ng-disabled="!container.State.Running"><i class="fa fa-stop space-right" aria-hidden="true"></i>Stop</button>
<button class="btn btn-danger" ng-click="kill()" ng-disabled="!container.State.Running"><i class="fa fa-bomb space-right" aria-hidden="true"></i>Kill</button>
<button class="btn btn-primary" ng-click="restart()" ng-disabled="!container.State.Running"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Restart</button>
<button class="btn btn-primary" ng-click="pause()" ng-disabled="!container.State.Running || container.State.Paused"><i class="fa fa-pause space-right" aria-hidden="true"></i>Pause</button>
<button class="btn btn-primary" 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" ng-click="remove()" ng-disabled="container.State.Running"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
</div>
</rd-widget-body>
@@ -101,11 +101,14 @@
<!-- name-and-registry-inputs -->
<div class="form-group">
<label for="image_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-7">
<div class="col-sm-11 col-md-6">
<input type="text" class="form-control" ng-model="config.Image" id="image_name" placeholder="e.g. myImage:myTag">
</div>
<label for="image_registry" class="col-sm-1 control-label text-left">Registry</label>
<div class="col-sm-3">
<label for="image_registry" class="col-sm-2 margin-sm-top control-label text-left">
Registry
<portainer-tooltip position="bottom" message="A registry to pull the image from. Leave empty to use the official Docker registry."></portainer-tooltip>
</label>
<div class="col-sm-10 col-md-3 margin-sm-top">
<input type="text" class="form-control" ng-model="config.Registry" id="image_registry" placeholder="optional">
</div>
</div>
@@ -119,7 +122,7 @@
<!-- !tag-note -->
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-default btn-sm" ng-disabled="!config.Image" ng-click="commit()">Create</button>
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Image" ng-click="commit()">Create</button>
<i id="createImageSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>

View File

@@ -1,6 +1,6 @@
angular.module('containerConsole', [])
.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Settings', 'Container', 'Image', 'Exec', '$timeout', 'Messages',
function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Messages) {
.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Settings', 'Container', 'Image', 'Exec', '$timeout', 'EndpointProvider', 'Messages',
function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, EndpointProvider, Messages) {
$scope.state = {};
$scope.state.loaded = false;
$scope.state.connected = false;
@@ -55,7 +55,7 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Mess
} else {
var execId = d.Id;
resizeTTY(execId, termHeight, termWidth);
var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId;
var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID();
if (url.indexOf('https') > -1) {
url = url.replace('https://', 'wss://');
} else {

View File

@@ -25,15 +25,15 @@
<rd-widget-taskbar classes="col-lg-12">
<div class="pull-left">
<div class="btn-group" role="group" aria-label="...">
<button type="button" class="btn btn-primary btn-responsive" ng-click="startAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-play space-right" aria-hidden="true"></i>Start</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="stopAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-stop space-right" aria-hidden="true"></i>Stop</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="killAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-bomb space-right" aria-hidden="true"></i>Kill</button>
<button type="button" class="btn btn-success btn-responsive" ng-click="startAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-play space-right" aria-hidden="true"></i>Start</button>
<button type="button" class="btn btn-danger btn-responsive" ng-click="stopAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-stop space-right" aria-hidden="true"></i>Stop</button>
<button type="button" class="btn btn-danger btn-responsive" ng-click="killAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-bomb space-right" aria-hidden="true"></i>Kill</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="restartAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Restart</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="pauseAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-pause space-right" aria-hidden="true"></i>Pause</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="unpauseAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-play space-right" aria-hidden="true"></i>Resume</button>
<button type="button" class="btn btn-danger btn-responsive" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
</div>
<a class="btn btn-default btn-responsive" type="button" ui-sref="actions.create.container">Add container</a>
<a class="btn btn-primary" type="button" ui-sref="actions.create.container"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add container</a>
</div>
<div class="pull-right">
<input type="checkbox" ng-model="state.displayAll" id="displayAll" ng-change="toggleGetAll()" style="margin-top: -2px; margin-right: 5px;"/><label for="displayAll">Show all containers</label>
@@ -90,15 +90,22 @@
<span ng-show="sortType == 'Ports' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">
<a ui-sref="containers" ng-click="order('Metadata.ResourceControl.OwnerId')">
Ownership
<span ng-show="sortType == 'Metadata.ResourceControl.OwnerId' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Metadata.ResourceControl.OwnerId' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="container in (state.filteredContainers = ( containers | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="container.Checked" ng-change="selectItem(container)"/></td>
<td><span class="label label-{{ container.Status|containerstatusbadge }}">{{ container.Status }}</span></td>
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|swarmcontainername}}</a></td>
<td ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|containername}}</a></td>
<td><a ui-sref="image({id: container.Image})">{{ container.Image }}</a></td>
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|swarmcontainername|truncate: 40}}</a></td>
<td ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|containername|truncate: 40}}</a></td>
<td><a ui-sref="image({id: container.Image})">{{ container.Image | hideshasum }}</a></td>
<td ng-if="state.displayIP">{{ container.IP ? container.IP : '-' }}</td>
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">{{ container.hostIP }}</td>
<td>
@@ -107,12 +114,43 @@
</a>
<span ng-if="container.Ports.length == 0" >-</span>
</td>
<td ng-if="applicationState.application.authentication">
<span ng-if="!container.Metadata.ResourceControl">
<i class="fa fa-eye" aria-hidden="true"></i>
<span ng-if="container.Labels['com.docker.swarm.service.id']">
Public service
</span>
<span ng-if="!container.Labels['com.docker.swarm.service.id']">
Public
</span>
</span>
<span ng-if="container.Metadata.ResourceControl.OwnerId === user.ID">
<i class="fa fa-eye-slash" aria-hidden="true"></i>
<span ng-if="container.Labels['com.docker.swarm.service.id']">
Private service
</span>
<span ng-if="!container.Labels['com.docker.swarm.service.id']">
Private
<a ng-click="switchOwnership(container)" class="interactive"><i class="fa fa-eye" aria-hidden="true" style="margin-left: 7px;"></i> Switch to public</a>
</span>
</span>
<span ng-if="container.Metadata.ResourceControl && container.Metadata.ResourceControl.OwnerId !== user.ID">
<i class="fa fa-eye-slash" aria-hidden="true"></i>
<span ng-if="container.Labels['com.docker.swarm.service.id']">
Private service <span ng-if="container.Owner">(owner: {{ container.Owner }})</span>
</span>
<span ng-if="!container.Labels['com.docker.swarm.service.id']">
Private <span ng-if="container.Owner">(owner: {{ container.Owner }})</span>
<a ng-click="switchOwnership(container)" class="interactive"><i class="fa fa-eye" aria-hidden="true" style="margin-left: 7px;"></i> Switch to public</a>
</span>
</span>
</td>
</tr>
<tr ng-if="!containers">
<td colspan="8" class="text-center text-muted">Loading...</td>
<td colspan="9" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="containers.length == 0">
<td colspan="8" class="text-center text-muted">No containers available.</td>
<td colspan="9" class="text-center text-muted">No containers available.</td>
</tr>
</tbody>
</table>

View File

@@ -1,6 +1,6 @@
angular.module('containers', [])
.controller('ContainersController', ['$scope', '$filter', 'Container', 'ContainerHelper', 'Info', 'Settings', 'Messages', 'Config', 'Pagination',
function ($scope, $filter, Container, ContainerHelper, Info, Settings, Messages, Config, Pagination) {
.controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerHelper', 'Info', 'Settings', 'Messages', 'Config', 'Pagination', 'EntityListService', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService',
function ($q, $scope, $filter, Container, ContainerHelper, Info, Settings, Messages, Config, Pagination, EntityListService, ModalService, Authentication, ResourceControlService, UserService) {
$scope.state = {};
$scope.state.pagination_count = Pagination.getPaginationCount('containers');
$scope.state.displayAll = Settings.displayAll;
@@ -17,8 +17,51 @@ function ($scope, $filter, Container, ContainerHelper, Info, Settings, Messages,
Pagination.setPaginationCount('containers', $scope.state.pagination_count);
};
function removeContainerResourceControl(container) {
volumeResourceControlQueries = [];
angular.forEach(container.Mounts, function (volume) {
volumeResourceControlQueries.push(ResourceControlService.removeVolumeResourceControl(container.Metadata.ResourceControl.OwnerId, volume.Name));
});
$q.all(volumeResourceControlQueries)
.then(function success() {
return ResourceControlService.removeContainerResourceControl(container.Metadata.ResourceControl.OwnerId, container.Id);
})
.then(function success() {
delete container.Metadata.ResourceControl;
Messages.send('Ownership changed to public', container.Id);
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to change container ownership");
});
}
$scope.switchOwnership = function(container) {
ModalService.confirmContainerOwnershipChange(function (confirmed) {
if(!confirmed) { return; }
removeContainerResourceControl(container);
});
};
function mapUsersToContainers(users) {
angular.forEach($scope.containers, function (container) {
if (container.Metadata) {
var containerRC = container.Metadata.ResourceControl;
if (containerRC && containerRC.OwnerId !== $scope.user.ID) {
angular.forEach(users, function (user) {
if (containerRC.OwnerId === user.Id) {
container.Owner = user.Username;
}
});
}
}
});
}
var update = function (data) {
$('#loadContainersSpinner').show();
var userDetails = Authentication.getUserDetails();
$scope.user = userDetails;
$scope.state.selectedItemCount = 0;
Container.query(data, function (d) {
var containers = d;
@@ -29,6 +72,10 @@ function ($scope, $filter, Container, ContainerHelper, Info, Settings, Messages,
var model = new ContainerViewModel(container);
model.Status = $filter('containerstatus')(model.Status);
EntityListService.rememberPreviousSelection($scope.containers, model, function onSelect(model){
$scope.selectItem(model);
});
if (model.IP) {
$scope.state.displayIP = true;
}
@@ -37,7 +84,20 @@ function ($scope, $filter, Container, ContainerHelper, Info, Settings, Messages,
}
return model;
});
$('#loadContainersSpinner').hide();
if (userDetails.role === 1) {
UserService.users()
.then(function success(data) {
mapUsersToContainers(data);
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to retrieve users");
})
.finally(function final() {
$('#loadContainersSpinner').hide();
});
} else {
$('#loadContainersSpinner').hide();
}
}, function (e) {
$('#loadContainersSpinner').hide();
Messages.error("Failure", e, "Unable to retrieve containers");
@@ -73,7 +133,17 @@ function ($scope, $filter, Container, ContainerHelper, Info, Settings, Messages,
Messages.send("Error", d.message);
}
else {
Messages.send("Container " + msg, c.Id);
if (c.Metadata && c.Metadata.ResourceControl) {
ResourceControlService.removeContainerResourceControl(c.Metadata.ResourceControl.OwnerId, c.Id)
.then(function success() {
Messages.send("Container " + msg, c.Id);
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to remove container ownership");
});
} else {
Messages.send("Container " + msg, c.Id);
}
}
complete();
}, function (e) {

View File

@@ -1,14 +1,18 @@
// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services.
// See app/components/templates/templatesController.js as a reference.
angular.module('createContainer', [])
.controller('CreateContainerController', ['$scope', '$state', '$stateParams', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'Messages',
function ($scope, $state, $stateParams, $filter, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, Messages) {
.controller('CreateContainerController', ['$scope', '$state', '$stateParams', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'ResourceControlService', 'Authentication', 'Messages',
function ($scope, $state, $stateParams, $filter, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, ResourceControlService, Authentication, Messages) {
$scope.formValues = {
Ownership: $scope.applicationState.application.authentication ? 'private' : '',
alwaysPull: true,
Console: 'none',
Volumes: [],
Registry: '',
NetworkContainer: '',
Labels: []
Labels: [],
ExtraHosts: []
};
$scope.imageConfig = {};
@@ -16,21 +20,24 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai
$scope.config = {
Image: '',
Env: [],
Cmd: '',
ExposedPorts: {},
HostConfig: {
RestartPolicy: {
Name: 'no'
},
PortBindings: [],
PublishAllPorts: false,
Binds: [],
NetworkMode: 'bridge',
Privileged: false
Privileged: false,
ExtraHosts: []
},
Labels: {}
};
$scope.addVolume = function() {
$scope.formValues.Volumes.push({ name: '', containerPath: '' });
$scope.formValues.Volumes.push({ name: '', containerPath: '', readOnly: false, type: 'volume' });
};
$scope.removeVolume = function(index) {
@@ -61,6 +68,15 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai
$scope.formValues.Labels.splice(index, 1);
};
$scope.addExtraHost = function() {
$scope.formValues.ExtraHosts.push({ value: '' });
};
$scope.removeExtraHost = function(index) {
$scope.formValues.ExtraHosts.splice(index, 1);
};
Config.$promise.then(function (c) {
var containersToHideLabels = c.hiddenLabels;
@@ -103,26 +119,40 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai
});
});
// TODO: centralize, already present in templatesController
function startContainer(containerID) {
Container.start({id: containerID}, {}, function (cd) {
if (cd.message) {
$('#createContainerSpinner').hide();
Messages.error('Error', {}, cd.message);
} else {
$('#createContainerSpinner').hide();
Messages.send('Container Started', containerID);
$state.go('containers', {}, {reload: true});
}
}, function (e) {
$('#createContainerSpinner').hide();
Messages.error("Failure", e, 'Unable to start container');
});
}
function createContainer(config) {
Container.create(config, function (d) {
if (d.message) {
$('#createContainerSpinner').hide();
Messages.error('Error', {}, d.message);
} else {
Container.start({id: d.Id}, {}, function (cd) {
if (cd.message) {
if ($scope.formValues.Ownership === 'private') {
ResourceControlService.setContainerResourceControl(Authentication.getUserDetails().ID, d.Id)
.then(function success() {
startContainer(d.Id);
})
.catch(function error(err) {
$('#createContainerSpinner').hide();
Messages.error('Error', {}, cd.message);
} else {
$('#createContainerSpinner').hide();
Messages.send('Container Started', d.Id);
$state.go('containers', {}, {reload: true});
}
}, function (e) {
$('#createContainerSpinner').hide();
Messages.error("Failure", e, 'Unable to start container');
});
Messages.error("Failure", err, 'Unable to apply resource control on container');
});
} else {
startContainer(d.Id);
}
}
}, function (e) {
$('#createContainerSpinner').hide();
@@ -130,7 +160,6 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai
});
}
// TODO: centralize, already present in templatesController
function pullImageAndCreateContainer(config) {
Image.create($scope.imageConfig, function (data) {
createContainer(config);
@@ -229,6 +258,12 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai
networkMode += ':' + containerName;
}
config.HostConfig.NetworkMode = networkMode;
$scope.formValues.ExtraHosts.forEach(function (v) {
if (v.value) {
config.HostConfig.ExtraHosts.push(v.value);
}
});
}
function prepareLabels(config) {
@@ -243,6 +278,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai
function prepareConfiguration() {
var config = angular.copy($scope.config);
config.Cmd = ContainerHelper.commandStringToArray(config.Cmd);
prepareNetworkConfig(config);
prepareImageConfig(config);
preparePortBindings(config);

View File

@@ -18,83 +18,130 @@
</div>
</div>
<!-- !name-input -->
<div class="col-sm-12 form-section-title">
Image configuration
</div>
<!-- image-and-registry-inputs -->
<div class="form-group">
<label for="container_image" class="col-sm-1 control-label text-left">Image</label>
<div class="col-sm-7">
<div class="col-sm-11 col-md-6">
<input type="text" class="form-control" ng-model="config.Image" id="container_image" placeholder="e.g. ubuntu:trusty">
</div>
<label for="image_registry" class="col-sm-1 control-label text-left">Registry</label>
<div class="col-sm-3">
<input type="text" class="form-control" ng-model="formValues.Registry" id="image_registry" placeholder="leave empty to use DockerHub">
</div>
<div class="col-sm-offset-1 col-sm-11">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="formValues.alwaysPull"> Always pull image before creating
</label>
</div>
<label for="image_registry" class="col-sm-2 margin-sm-top control-label text-left">
Registry
<portainer-tooltip position="bottom" message="A registry to pull the image from. Leave empty to use the official Docker registry."></portainer-tooltip>
</label>
<div class="col-sm-10 col-md-3 margin-sm-top">
<input type="text" class="form-control" ng-model="formValues.Registry" id="image_registry" placeholder="e.g. myregistry.mydomain">
</div>
</div>
<!-- !image-and-registry-inputs -->
<!-- restart-policy -->
<!-- always-pull -->
<div class="form-group">
<label class="col-sm-1 control-label text-left">Restart policy</label>
<div class="col-sm-11">
<label class="radio-inline">
<input type="radio" name="container_restart_policy" ng-model="config.HostConfig.RestartPolicy.Name" value="no">
Never
<div class="col-sm-12">
<label for="ownership" class="control-label text-left">
Always pull the image
<portainer-tooltip position="bottom" message="When enabled, Portainer will automatically try to pull the specified image before creating the container."></portainer-tooltip>
</label>
<label class="radio-inline">
<input type="radio" name="container_restart_policy" ng-model="config.HostConfig.RestartPolicy.Name" value="always">
Always
</label>
<label class="radio-inline">
<input type="radio" name="container_restart_policy" ng-model="config.HostConfig.RestartPolicy.Name" value="on-failure">
<span class="radio-value">On failure</span>
</label>
<label class="radio-inline">
<input type="radio" name="container_restart_policy" ng-model="config.HostConfig.RestartPolicy.Name" value="unless-stopped">
<span class="radio-value">Unless stopped</span>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="formValues.alwaysPull"><i></i>
</label>
</div>
</div>
<!-- !restart-policy -->
<!-- !always-pull -->
<div class="col-sm-12 form-section-title">
Ports configuration
</div>
<!-- publish-exposed-ports -->
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Publish all exposed ports
<portainer-tooltip position="bottom" message="When enabled, Portainer will let Docker automatically map a random port on the host to each one defined in the image Dockerfile."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="config.HostConfig.PublishAllPorts"><i></i>
</label>
</div>
</div>
<!-- !publish-exposed-ports -->
<!-- port-mapping -->
<div class="form-group">
<label for="container_ports" class="col-sm-1 control-label text-left">Port mapping</label>
<div class="col-sm-11">
<span class="label label-default interactive" ng-click="addPortBinding()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> map port
<div class="col-sm-12">
<label class="control-label text-left">Port mapping</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addPortBinding()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional port
</span>
</div>
<!-- port-mapping-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="portBinding in config.HostConfig.PortBindings" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<!-- host-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="portBinding.hostPort" placeholder="e.g. 80 or 1.2.3.4:80 (optional)">
</div>
<div class="input-group col-sm-5 input-group-sm">
<!-- !host-port -->
<span style="margin: 0 10px 0 10px;">
<i class="fa fa-long-arrow-right" aria-hidden="true"></i>
</span>
<!-- container-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="portBinding.containerPort" placeholder="e.g. 80">
</div>
<div class="input-group col-sm-1 input-group-sm">
<select class="form-control" ng-model="portBinding.protocol">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removePortBinding($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
<!-- !container-port -->
<!-- protocol-actions -->
<div class="input-group col-sm-3 input-group-sm">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="portBinding.protocol" uib-btn-radio="'tcp'">TCP</label>
<label class="btn btn-primary" ng-model="portBinding.protocol" uib-btn-radio="'udp'">UDP</label>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removePortBinding($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
<!-- !protocol-actions -->
</div>
</div>
<!-- !port-mapping-input-list -->
</div>
<!-- !port-mapping -->
<div class="col-sm-12 form-section-title" ng-if="applicationState.application.authentication">
Access control
</div>
<!-- ownership -->
<div class="form-group" ng-if="applicationState.application.authentication">
<div class="col-sm-12">
<label for="ownership" class="control-label text-left">
Ownership
<portainer-tooltip position="bottom" message="When setting the ownership value to private, only you and the administrators will be able to see and manage this object. When choosing public, everybody will be able to access it."></portainer-tooltip>
</label>
<div class="btn-group btn-group-sm" style="margin-left: 20px;">
<label class="btn btn-primary" ng-model="formValues.Ownership" uib-btn-radio="'private'">
<i class="fa fa-eye-slash" aria-hidden="true"></i>
Private
</label>
<label class="btn btn-primary" ng-model="formValues.Ownership" uib-btn-radio="'public'">
<i class="fa fa-eye" aria-hidden="true"></i>
Public
</label>
</div>
</div>
</div>
<!-- !ownership -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Image" ng-click="create()">Start container</button>
<a type="button" class="btn btn-default btn-sm" ui-sref="containers">Cancel</a>
<i id="createContainerSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
@@ -104,13 +151,16 @@
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-cog" title="Advanced container settings"></rd-widget-header>
<rd-widget-body>
<ul class="nav nav-tabs">
<ul class="nav nav-pills nav-justified">
<li class="active interactive"><a data-target="#command" data-toggle="tab">Command</a></li>
<li class="interactive"><a data-target="#volumes" data-toggle="tab">Volumes</a></li>
<li class="interactive"><a data-target="#network" data-toggle="tab">Network</a></li>
<li class="interactive"><a data-target="#env" data-toggle="tab">Env</a></li>
<li class="interactive"><a data-target="#labels" data-toggle="tab">Labels</a></li>
<li class="interactive"><a data-target="#security" data-toggle="tab">Security/Host</a></li>
<li class="interactive"><a data-target="#restart-policy" data-toggle="tab">Restart policy</a></li>
<li class="interactive"><a data-target="#runtime" data-toggle="tab">Runtime</a></li>
</ul>
<!-- tab-content -->
<div class="tab-content">
@@ -119,7 +169,7 @@
<form class="form-horizontal" style="margin-top: 15px;">
<!-- command-input -->
<div class="form-group">
<label for="container_command" class="col-sm-1 control-label text-left">Command</label>
<label for="container_command" class="col-sm-2 col-lg-1 control-label text-left">Command</label>
<div class="col-sm-9">
<input type="text" class="form-control" ng-model="config.Cmd" id="container_command" placeholder="e.g. /usr/bin/nginx -t -c /mynginx.conf">
</div>
@@ -127,7 +177,7 @@
<!-- !command-input -->
<!-- entrypoint-input -->
<div class="form-group">
<label for="container_entrypoint" class="col-sm-1 control-label text-left">Entry Point</label>
<label for="container_entrypoint" class="col-sm-2 col-lg-1 control-label text-left">Entry Point</label>
<div class="col-sm-9">
<input type="text" class="form-control" ng-model="config.Entrypoint" id="container_entrypoint" placeholder="e.g. /bin/sh -c">
</div>
@@ -135,7 +185,7 @@
<!-- !entrypoint-input -->
<!-- workdir-user-input -->
<div class="form-group">
<label for="container_workingdir" class="col-sm-1 control-label text-left">Working Dir</label>
<label for="container_workingdir" class="col-sm-2 col-lg-1 control-label text-left">Working Dir</label>
<div class="col-sm-4">
<input type="text" class="form-control" ng-model="config.WorkingDir" id="container_workingdir" placeholder="e.g. /myapp">
</div>
@@ -147,8 +197,8 @@
<!-- !workdir-user-input -->
<!-- console -->
<div class="form-group">
<label for="container_console" class="col-sm-1 control-label text-left">Console</label>
<div class="col-sm-11">
<label for="container_console" class="col-sm-2 col-lg-1 control-label text-left">Console</label>
<div class="col-sm-10 col-lg-11">
<div class="col-sm-4">
<label class="radio-inline">
<input type="radio" name="container_console" ng-model="formValues.Console" value="both">
@@ -162,7 +212,7 @@
</label>
</div>
</div>
<div class="col-sm-offset-1 col-sm-11">
<div class="col-sm-offset-2 col-sm-10 col-lg-offset-1 col-lg-11">
<div class="col-sm-4">
<label class="radio-inline">
<input type="radio" name="container_console" ng-model="formValues.Console" value="tty">
@@ -178,35 +228,6 @@
</div>
</div>
<!-- !console -->
<!-- environment-variables -->
<div class="form-group">
<label for="container_env" class="col-sm-1 control-label text-left">Environment variables</label>
<div class="col-sm-11">
<span class="label label-default interactive" ng-click="addEnvironmentVariable()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> environment variable
</span>
</div>
<!-- environment-variable-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div ng-repeat="variable in config.Env" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="variable.name" placeholder="e.g. FOO">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. bar">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeEnvironmentVariable($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
</div>
</div>
</div>
<!-- !environment-variable-input-list -->
</div>
<!-- !environment-variables -->
</form>
</div>
<!-- !tab-command -->
@@ -215,39 +236,64 @@
<form class="form-horizontal" style="margin-top: 15px;">
<!-- volumes -->
<div class="form-group">
<label for="container_volumes" class="col-sm-1 control-label text-left">Volumes</label>
<div class="col-sm-11">
<span class="label label-default interactive" ng-click="addVolume()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> volume
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Volume mapping</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addVolume()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional volume
</span>
</div>
<!-- volumes-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div ng-repeat="volume in formValues.Volumes" style="margin-top: 2px;">
<div class="input-group col-sm-1 input-group-sm">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="volume.readOnly"> Read-only
</label>
<div class="form-inline" style="margin-top: 10px;">
<div ng-repeat="volume in formValues.Volumes">
<!-- volume-line1 -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<!-- container-path -->
<div class="input-group input-group-sm col-sm-6">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="volume.containerPath" placeholder="e.g. /path/in/container">
</div>
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon"><input type="checkbox" ng-model="volume.isPath" ng-click="resetVolumePath($index)">Path</span>
<select class="form-control" ng-model="volume.name" ng-if="!volume.isPath">
<option selected disabled hidden value="">Select a volume</option>
<option ng-repeat="vol in availableVolumes" ng-value="vol.Name">{{ vol.Name|truncate:30}}</option>
</select>
<input ng-if="volume.isPath" type="text" class="form-control" ng-model="volume.name" placeholder="e.g. /path/on/host">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="volume.containerPath" placeholder="e.g. /path/in/container">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeVolume($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
<!-- !container-path -->
<!-- volume-type -->
<div class="input-group col-sm-5" style="margin-left: 5px;">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.name = ''">Bind</label>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeVolume($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</span>
</div>
<!-- !volume-type -->
</div>
<!-- !volume-line1 -->
<!-- volume-line2 -->
<div class="col-sm-12 form-inline" style="margin-top: 5px;">
<i class="fa fa-long-arrow-right" aria-hidden="true"></i>
<!-- volume -->
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'volume'">
<span class="input-group-addon">volume</span>
<select class="form-control" ng-model="volume.name">
<option selected disabled hidden value="">Select a volume</option>
<option ng-repeat="vol in availableVolumes" ng-value="vol.Name">{{ vol.Name|truncate:30}}</option>
</select>
</div>
<!-- !volume -->
<!-- bind -->
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'bind'">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="volume.name" placeholder="e.g. /path/on/host">
</div>
<!-- !bind -->
<!-- read-only -->
<div class="input-group input-group-sm col-sm-5" style="margin-left: 5px;">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="volume.readOnly" uib-btn-radio="false">Writable</label>
<label class="btn btn-primary" ng-model="volume.readOnly" uib-btn-radio="true">Read-only</label>
</div>
</div>
<!-- !read-only -->
</div>
<!-- !volume-line2 -->
</div>
</div>
<!-- !volumes-input-list -->
@@ -266,7 +312,7 @@
</div>
<!-- network-input -->
<div class="form-group">
<label for="container_network" class="col-sm-1 control-label text-left">Network</label>
<label for="container_network" class="col-sm-2 col-lg-1 control-label text-left">Network</label>
<div class="col-sm-9">
<select class="form-control" ng-model="config.HostConfig.NetworkMode" id="container_network">
<option selected disabled hidden value="">Select a network</option>
@@ -277,7 +323,7 @@
<!-- !network-input -->
<!-- container-name-input -->
<div class="form-group" ng-if="config.HostConfig.NetworkMode == 'container'">
<label for="container_network_container" class="col-sm-1 control-label text-left">Container</label>
<label for="container_network_container" class="col-sm-2 col-lg-1 control-label text-left">Container</label>
<div class="col-sm-9">
<select ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'" ng-options="container|containername for container in runningContainers" class="form-control" ng-model="formValues.NetworkContainer">
<option selected disabled hidden value="">Select a container</option>
@@ -290,7 +336,7 @@
<!-- !container-name-input -->
<!-- hostname-input -->
<div class="form-group">
<label for="container_hostname" class="col-sm-1 control-label text-left">Hostname</label>
<label for="container_hostname" class="col-sm-2 col-lg-1 control-label text-left">Hostname</label>
<div class="col-sm-9">
<input type="text" class="form-control" ng-model="config.Hostname" id="container_hostname" placeholder="e.g. web01">
</div>
@@ -298,12 +344,35 @@
<!-- !hostname-input -->
<!-- domainname-input -->
<div class="form-group">
<label for="container_domainname" class="col-sm-1 control-label text-left">Domain Name</label>
<label for="container_domainname" class="col-sm-2 col-lg-1 control-label text-left">Domain Name</label>
<div class="col-sm-9">
<input type="text" class="form-control" ng-model="config.Domainname" id="container_domainname" placeholder="e.g. example.com">
</div>
</div>
<!-- !domainname -->
<!-- extra-hosts-variables -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Hosts file entries</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addExtraHost()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add additional entry
</span>
</div>
<!-- extra-hosts-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="variable in formValues.ExtraHosts" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. host:IP">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeExtraHost($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !extra-hosts-input-list -->
</div>
<!-- !extra-hosts-variables -->
</form>
</div>
<!-- !tab-network -->
@@ -312,14 +381,14 @@
<form class="form-horizontal" style="margin-top: 15px;">
<!-- labels -->
<div class="form-group">
<label for="container_labels" class="col-sm-1 control-label text-left">Labels</label>
<div class="col-sm-11">
<span class="label label-default interactive" ng-click="addLabel()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> label
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Labels</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addLabel()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add label
</span>
</div>
<!-- labels-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="label in formValues.Labels" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
@@ -328,12 +397,10 @@
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeLabel($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeLabel($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !labels-input-list -->
@@ -342,35 +409,86 @@
</form>
</div>
<!-- !tab-labels -->
<!-- tab-security -->
<div class="tab-pane" id="security">
<!-- tab-env -->
<div class="tab-pane" id="env">
<form class="form-horizontal" style="margin-top: 15px;">
<!-- environment-variables -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Environment variables</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addEnvironmentVariable()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add environment variable
</span>
</div>
<!-- environment-variable-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="variable in config.Env" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="variable.name" placeholder="e.g. FOO">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. bar">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeEnvironmentVariable($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !environment-variable-input-list -->
</div>
<!-- !environment-variables -->
</form>
</div>
<!-- !tab-labels -->
<!-- tab-restart-policy -->
<div class="tab-pane" id="restart-policy">
<form class="form-horizontal" style="margin-top: 15px;">
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Restart policy
</label>
<div class="btn-group btn-group-sm" style="margin-left: 20px;">
<label class="btn btn-primary" ng-model="config.HostConfig.RestartPolicy.Name" uib-btn-radio="'no'">
Never
</label>
<label class="btn btn-primary" ng-model="config.HostConfig.RestartPolicy.Name" uib-btn-radio="'always'">
Always
</label>
<label class="btn btn-primary" ng-model="config.HostConfig.RestartPolicy.Name" uib-btn-radio="'on-failure'">
On failure
</label>
<label class="btn btn-primary" ng-model="config.HostConfig.RestartPolicy.Name" uib-btn-radio="'unless-stopped'">
Unless stopped
</label>
</div>
</div>
</div>
</form>
</div>
<!-- !tab-restart-policy -->
<!-- tab-runtime -->
<div class="tab-pane" id="runtime">
<form class="form-horizontal" style="margin-top: 15px;">
<!-- privileged-mode -->
<div class="form-group">
<div class="col-sm-12">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="config.HostConfig.Privileged"> Privileged mode
</label>
</div>
<label for="ownership" class="control-label text-left">
Privileged mode
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="config.HostConfig.Privileged"><i></i>
</label>
</div>
</div>
<!-- !privileged-mode -->
</form>
</div>
<!-- !tab-security -->
<!-- !tab-runtime -->
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12" style="text-align: center;">
<div>
<i id="createContainerSpinner" class="fa fa-cog fa-3x fa-spin" style="margin-bottom: 5px; display: none;"></i>
</div>
<button type="button" class="btn btn-default btn-lg" ng-click="create()">Create</button>
<a type="button" class="btn btn-default btn-lg" ui-sref="containers">Cancel</a>
</div>
</div>

View File

@@ -18,36 +18,45 @@
</div>
</div>
<!-- !name-input -->
<div class="col-sm-12 form-section-title">
Network configuration
</div>
<!-- subnet-gateway-inputs -->
<div class="form-group">
<label for="network_subnet" class="col-sm-1 control-label text-left">Subnet</label>
<div class="col-sm-5">
<label for="network_subnet" class="col-sm-2 col-lg-1 control-label text-left">Subnet</label>
<div class="col-sm-4 col-lg-5">
<input type="text" class="form-control" ng-model="formValues.Subnet" id="network_subnet" placeholder="e.g. 172.20.0.0/16">
</div>
<label for="network_gateway" class="col-sm-1 control-label text-left">Gateway</label>
<div class="col-sm-5">
<label for="network_gateway" class="col-sm-2 col-lg-1 control-label text-left">Gateway</label>
<div class="col-sm-4 col-lg-5">
<input type="text" class="form-control" ng-model="formValues.Gateway" id="network_gateway" placeholder="e.g. 172.20.10.11">
</div>
</div>
<!-- !subnet-gateway-inputs -->
<div class="col-sm-12 form-section-title">
Driver configuration
</div>
<!-- driver-input -->
<div class="form-group">
<label for="network_driver" class="col-sm-1 control-label text-left">Driver</label>
<div class="col-sm-11">
<label for="network_driver" class="col-sm-2 col-lg-1 control-label text-left">Driver</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="config.Driver" id="network_driver" placeholder="e.g. driverName">
</div>
</div>
<!-- !driver-input -->
<!-- driver-options -->
<div class="form-group">
<label for="network_driveropts" class="col-sm-1 control-label text-left">Driver options</label>
<div class="col-sm-11">
<span class="label label-default interactive" ng-click="addDriverOption()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> driver option
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">
Driver options
<portainer-tooltip position="bottom" message="Driver options are specific to the selected driver. Please refer to the selected driver documentation."></portainer-tooltip>
</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addDriverOption()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add driver option
</span>
</div>
<!-- driver-options-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="option in formValues.DriverOptions" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
@@ -56,38 +65,28 @@
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="option.value" placeholder="e.g. true">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeDriverOption($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeDriverOption($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !driver-options-input-list -->
</div>
<!-- !driver-options -->
<!-- internal -->
<div class="form-group">
<div class="col-sm-12">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="config.Internal"> Restrict external access to the network
</label>
</div>
</div>
<div class="col-sm-12 form-section-title">
Advanced configuration
</div>
<!-- !internal -->
<!-- labels -->
<div class="form-group">
<label for="service_env" class="col-sm-1 control-label text-left">Labels</label>
<div class="col-sm-11">
<span class="label label-default interactive" ng-click="addLabel()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> label
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Labels</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addLabel()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add label
</span>
</div>
<!-- labels-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="label in formValues.Labels" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
@@ -96,29 +95,41 @@
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeLabel($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeLabel($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !labels-input-list -->
</div>
<!-- !labels-->
<!-- internal -->
<div class="form-group">
<div class="col-sm-12">
<label for="ownership" class="control-label text-left">
Restrict external access to the network
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="formValues.alwaysPull"><i></i>
</label>
</div>
</div>
<!-- !internal -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Name" ng-click="create()">Create network</button>
<a type="button" class="btn btn-default btn-sm" ui-sref="networks">Cancel</a>
<i id="createNetworkSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12" style="text-align: center;">
<div>
<i id="createNetworkSpinner" class="fa fa-cog fa-3x fa-spin" style="margin-bottom: 5px; display: none;"></i>
</div>
<button type="button" class="btn btn-default btn-lg" ng-disabled="!config.Name" ng-click="create()">Create</button>
<a type="button" class="btn btn-default btn-lg" ui-sref="networks">Cancel</a>
</div>
</div>

View File

@@ -1,8 +1,11 @@
// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services.
// See app/components/templates/templatesController.js as a reference.
angular.module('createService', [])
.controller('CreateServiceController', ['$scope', '$state', 'Service', 'Volume', 'Network', 'ImageHelper', 'Messages',
function ($scope, $state, Service, Volume, Network, ImageHelper, Messages) {
.controller('CreateServiceController', ['$scope', '$state', 'Service', 'Volume', 'Network', 'ImageHelper', 'Authentication', 'ResourceControlService', 'Messages',
function ($scope, $state, Service, Volume, Network, ImageHelper, Authentication, ResourceControlService, Messages) {
$scope.formValues = {
Ownership: $scope.applicationState.application.authentication ? 'private' : '',
Name: '',
Image: '',
Registry: '',
@@ -41,7 +44,7 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Messages) {
};
$scope.addVolume = function() {
$scope.formValues.Volumes.push({ name: '', containerPath: '' });
$scope.formValues.Volumes.push({ Source: '', Target: '', ReadOnly: false, Type: 'volume' });
};
$scope.removeVolume = function(index) {
@@ -205,9 +208,22 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Messages) {
function createNewService(config) {
Service.create(config, function (d) {
$('#createServiceSpinner').hide();
Messages.send('Service created', d.ID);
$state.go('services', {}, {reload: true});
if ($scope.formValues.Ownership === 'private') {
ResourceControlService.setServiceResourceControl(Authentication.getUserDetails().ID, d.ID)
.then(function success() {
$('#createServiceSpinner').hide();
Messages.send('Service created', d.ID);
$state.go('services', {}, {reload: true});
})
.catch(function error(err) {
$('#createContainerSpinner').hide();
Messages.error("Failure", err, 'Unable to apply resource control on service');
});
} else {
$('#createServiceSpinner').hide();
Messages.send('Service created', d.ID);
$state.go('services', {}, {reload: true});
}
}, function (e) {
$('#createServiceSpinner').hide();
Messages.error("Failure", e, 'Unable to create service');

View File

@@ -18,75 +18,127 @@
</div>
</div>
<!-- !name-input -->
<div class="col-sm-12 form-section-title">
Image configuration
</div>
<!-- image-and-registry-inputs -->
<div class="form-group">
<label for="service_image" class="col-sm-1 control-label text-left">Image</label>
<div class="col-sm-7">
<div class="col-sm-11 col-md-6">
<input type="text" class="form-control" ng-model="formValues.Image" id="service_image" placeholder="e.g. nginx:latest">
</div>
<label for="image_registry" class="col-sm-1 control-label text-left">Registry</label>
<div class="col-sm-3">
<input type="text" class="form-control" ng-model="formValues.Registry" id="image_registry" placeholder="leave empty to use DockerHub">
<label for="image_registry" class="col-sm-2 margin-sm-top control-label text-left">
Registry
<portainer-tooltip position="bottom" message="A registry to pull the image from. Leave empty to use the official Docker registry."></portainer-tooltip>
</label>
<div class="col-sm-10 col-md-3 margin-sm-top">
<input type="text" class="form-control" ng-model="formValues.Registry" id="image_registry" placeholder="e.g. myregistry.mydomain">
</div>
</div>
<!-- !image-and-registry-inputs -->
<div class="col-sm-12 form-section-title">
Scheduling
</div>
<!-- scheduling-mode -->
<div class="form-group">
<label class="col-sm-1 control-label text-left">Scheduling mode</label>
<div class="col-sm-11">
<label class="radio-inline">
<input type="radio" name="service_scheduling" ng-model="formValues.Mode" value="global">
Global
</label>
<label class="radio-inline">
<input type="radio" name="service_scheduling" ng-model="formValues.Mode" value="replicated">
Replicated
<div class="col-sm-12">
<label class="control-label text-left">
Scheduling mode
</label>
<div class="btn-group btn-group-sm" style="margin-left: 20px;">
<label class="btn btn-primary" ng-model="formValues.Mode" uib-btn-radio="'global'">Global</label>
<label class="btn btn-primary" ng-model="formValues.Mode" uib-btn-radio="'replicated'">Replicated</label>
</div>
</div>
</div>
<div class="form-group" ng-if="formValues.Mode === 'replicated'">
<label for="replicas" class="col-sm-1 control-label text-left">Replicas</label>
<div class="col-sm-1">
<input type="number" class="form-control" ng-model="formValues.Replicas" id="replicas" placeholder="e.g. 3">
<div class="form-group form-inline" ng-if="formValues.Mode === 'replicated'">
<div class="col-sm-12">
<label class="control-label text-left">
Replicas
</label>
<input type="number" class="form-control" ng-model="formValues.Replicas" id="replicas" placeholder="e.g. 3" style="margin-left: 20px;">
</div>
<div class="col-sm-10"></div>
</div>
<!-- !scheduling-mode -->
<div class="col-sm-12 form-section-title">
Ports configuration
</div>
<!-- port-mapping -->
<div class="form-group">
<label for="container_ports" class="col-sm-1 control-label text-left">Port mapping</label>
<div class="col-sm-11">
<span class="label label-default interactive" ng-click="addPortBinding()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> map port
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Port mapping</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addPortBinding()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional port
</span>
</div>
<!-- port-mapping-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="portBinding in formValues.Ports" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<!-- host-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="portBinding.PublishedPort" placeholder="e.g. 8080">
<input type="text" class="form-control" ng-model="portBinding.PublishedPort" placeholder="e.g. 80 or 1.2.3.4:80 (optional)">
</div>
<div class="input-group col-sm-5 input-group-sm">
<!-- !host-port -->
<span style="margin: 0 10px 0 10px;">
<i class="fa fa-long-arrow-right" aria-hidden="true"></i>
</span>
<!-- container-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="portBinding.TargetPort" placeholder="e.g. 80">
</div>
<div class="input-group col-sm-1 input-group-sm">
<select class="form-control" ng-model="portBinding.Protocol">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removePortBinding($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
<!-- !container-port -->
<!-- protocol-actions -->
<div class="input-group col-sm-3 input-group-sm">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="portBinding.Protocol" uib-btn-radio="'tcp'">TCP</label>
<label class="btn btn-primary" ng-model="portBinding.Protocol" uib-btn-radio="'udp'">UDP</label>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removePortBinding($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
<!-- !protocol-actions -->
</div>
</div>
<!-- !port-mapping-input-list -->
</div>
<!-- !port-mapping -->
<div class="col-sm-12 form-section-title" ng-if="applicationState.application.authentication">
Access control
</div>
<!-- ownership -->
<div class="form-group" ng-if="applicationState.application.authentication">
<div class="col-sm-12">
<label for="ownership" class="control-label text-left">
Ownership
<portainer-tooltip position="bottom" message="When setting the ownership value to private, only you and the administrators will be able to see and manage this object. When choosing public, everybody will be able to access it."></portainer-tooltip>
</label>
<div class="btn-group btn-group-sm" style="margin-left: 20px;">
<label class="btn btn-primary" ng-model="formValues.Ownership" uib-btn-radio="'private'">
<i class="fa fa-eye-slash" aria-hidden="true"></i>
Private
</label>
<label class="btn btn-primary" ng-model="formValues.Ownership" uib-btn-radio="'public'">
<i class="fa fa-eye" aria-hidden="true"></i>
Public
</label>
</div>
</div>
</div>
<!-- !ownership -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.Image" ng-click="create()">Create service</button>
<a type="button" class="btn btn-default btn-sm" ui-sref="services">Cancel</a>
<i id="createServiceSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
@@ -97,7 +149,7 @@
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-body>
<ul class="nav nav-tabs">
<ul class="nav nav-pills nav-justified">
<li class="active interactive"><a data-target="#command" data-toggle="tab">Command</a></li>
<li class="interactive"><a data-target="#volumes" data-toggle="tab">Volumes</a></li>
<li class="interactive"><a data-target="#network" data-toggle="tab">Network</a></li>
@@ -111,7 +163,7 @@
<form class="form-horizontal" style="margin-top: 15px;">
<!-- command-input -->
<div class="form-group">
<label for="service_command" class="col-sm-1 control-label text-left">Command</label>
<label for="service_command" class="col-sm-2 col-lg-1 control-label text-left">Command</label>
<div class="col-sm-9">
<input type="text" class="form-control" ng-model="formValues.Command" id="service_command" placeholder="e.g. /usr/bin/nginx -t -c /mynginx.conf">
</div>
@@ -119,7 +171,7 @@
<!-- !command-input -->
<!-- entrypoint-input -->
<div class="form-group">
<label for="service_entrypoint" class="col-sm-1 control-label text-left">Entrypoint</label>
<label for="service_entrypoint" class="col-sm-2 col-lg-1 control-label text-left">Entrypoint</label>
<div class="col-sm-9">
<input type="text" class="form-control" ng-model="formValues.EntryPoint" id="service_entrypoint" placeholder="e.g. /bin/sh -c">
</div>
@@ -127,7 +179,7 @@
<!-- !entrypoint-input -->
<!-- workdir-user-input -->
<div class="form-group">
<label for="service_workingdir" class="col-sm-1 control-label text-left">Working Dir</label>
<label for="service_workingdir" class="col-sm-2 col-lg-1 control-label text-left">Working Dir</label>
<div class="col-sm-4">
<input type="text" class="form-control" ng-model="formValues.WorkingDir" id="service_workingdir" placeholder="e.g. /myapp">
</div>
@@ -139,14 +191,14 @@
<!-- !workdir-user-input -->
<!-- environment-variables -->
<div class="form-group">
<label for="service_env" class="col-sm-1 control-label text-left">Environment variables</label>
<div class="col-sm-11">
<span class="label label-default interactive" ng-click="addEnvironmentVariable()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> environment variable
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Environment variables</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addEnvironmentVariable()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add environment variable
</span>
</div>
<!-- environment-variable-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="variable in formValues.Env" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
@@ -155,12 +207,10 @@
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. bar">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeEnvironmentVariable($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeEnvironmentVariable($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !environment-variable-input-list -->
@@ -174,38 +224,65 @@
<form class="form-horizontal" style="margin-top: 15px;">
<!-- volumes -->
<div class="form-group">
<label for="service_volumes" class="col-sm-1 control-label text-left">Volumes</label>
<div class="col-sm-11">
<span class="label label-default interactive" ng-click="addVolume()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> volume
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Volume mapping</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addVolume()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional volume
</span>
</div>
<!-- volumes-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div ng-repeat="volume in formValues.Volumes" style="margin-top: 2px;">
<div class="input-group col-sm-1 input-group-sm">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="volume.ReadOnly"> Read-only
</label>
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="volume in formValues.Volumes">
<div class="col-sm-12" style="margin-top: 10px;">
<!-- volume-line1 -->
<div class="col-sm-12 form-inline">
<!-- container-path -->
<div class="input-group input-group-sm col-sm-6">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="volume.Source" placeholder="e.g. /path/in/container">
</div>
<!-- !container-path -->
<!-- volume-type -->
<div class="input-group col-sm-5" style="margin-left: 5px;">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
<label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'bind'" ng-click="volume.Name = ''">Bind</label>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeVolume($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
<!-- !volume-type -->
</div>
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon"><input type="checkbox" ng-model="volume.Bind">bind</span>
<select class="form-control" ng-model="volume.Source" ng-if="!volume.Bind">
<option selected disabled hidden value="">Select a volume</option>
<option ng-repeat="vol in availableVolumes" ng-value="vol.Name">{{ vol.Name|truncate:30}}</option>
</select>
<input ng-if="volume.Bind" type="text" class="form-control" ng-model="volume.Source" placeholder="e.g. /path/on/host">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="volume.Target" placeholder="e.g. /path/in/container">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeVolume($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
<!-- !volume-line1 -->
<!-- volume-line2 -->
<div class="col-sm-12 form-inline" style="margin-top: 5px;">
<i class="fa fa-long-arrow-right" aria-hidden="true"></i>
<!-- volume -->
<div class="input-group input-group-sm col-sm-6" ng-if="volume.Type === 'volume'">
<span class="input-group-addon">volume</span>
<select class="form-control" ng-model="volume.Target">
<option selected disabled hidden value="">Select a volume</option>
<option ng-repeat="vol in availableVolumes" ng-value="vol.Name">{{ vol.Name|truncate:30}}</option>
</select>
</div>
<!-- !volume -->
<!-- bind -->
<div class="input-group input-group-sm col-sm-6" ng-if="volume.Type === 'bind'">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="volume.Target" placeholder="e.g. /path/on/host">
</div>
<!-- !bind -->
<!-- read-only -->
<div class="input-group input-group-sm col-sm-5" style="margin-left: 5px;">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="volume.ReadOnly" uib-btn-radio="false">Writable</label>
<label class="btn btn-primary" ng-model="volume.ReadOnly" uib-btn-radio="true">Read-only</label>
</div>
</div>
<!-- !read-only -->
</div>
<!-- !volume-line2 -->
</div>
</div>
</div>
@@ -220,7 +297,7 @@
<form class="form-horizontal" style="margin-top: 15px;">
<!-- network-input -->
<div class="form-group">
<label for="container_network" class="col-sm-1 control-label text-left">Network</label>
<label for="container_network" class="col-sm-2 col-lg-1 control-label text-left">Network</label>
<div class="col-sm-9">
<select class="form-control" ng-model="formValues.Network">
<option selected disabled hidden value="">Select a network</option>
@@ -232,27 +309,22 @@
<!-- !network-input -->
<!-- extra-networks -->
<div class="form-group">
<label for="service_extra_networks" class="col-sm-1 control-label text-left">Extra networks</label>
<div class="col-sm-11">
<span class="label label-default interactive" ng-click="addExtraNetwork()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> network
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Extra networks</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addExtraNetwork()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add extra network
</span>
</div>
<!-- network-input-list -->
<div style="margin-top: 10px;">
<div class="col-sm-12" ng-repeat="network in formValues.ExtraNetworks" style="margin-top: 5px;">
<div class="input-group col-sm-9 input-group-sm col-sm-offset-1">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeExtraNetwork($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
<select class="form-control" ng-model="network.Name">
<option selected disabled hidden value="">Select a network</option>
<option ng-repeat="net in availableNetworks" ng-value="net.Name">{{ net.Name }}</option>
</select>
</div>
<div class="col-sm-2"></div>
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="network in formValues.ExtraNetworks" style="margin-top: 2px;">
<select class="form-control" ng-model="network.Name">
<option selected disabled hidden value="">Select a network</option>
<option ng-repeat="net in availableNetworks" ng-value="net.Name">{{ net.Name }}</option>
</select>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeExtraNetwork($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !network-input-list -->
@@ -266,14 +338,14 @@
<form class="form-horizontal" style="margin-top: 15px;">
<!-- labels -->
<div class="form-group">
<label for="service_env" class="col-sm-1 control-label text-left">Labels</label>
<div class="col-sm-11">
<span class="label label-default interactive" ng-click="addLabel()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> label
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Service labels</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addLabel()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add service label
</span>
</div>
<!-- labels-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="label in formValues.Labels" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
@@ -282,12 +354,10 @@
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeLabel($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeLabel($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !labels-input-list -->
@@ -295,14 +365,14 @@
<!-- !labels-->
<!-- container-labels -->
<div class="form-group">
<label for="service_env" class="col-sm-1 control-label text-left">Container labels</label>
<div class="col-sm-11">
<span class="label label-default interactive" ng-click="addContainerLabel()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> container label
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Container labels</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addContainerLabel()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add container label
</span>
</div>
<!-- container-labels-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="label in formValues.ContainerLabels" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
@@ -311,12 +381,10 @@
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeContainerLabel($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeContainerLabel($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !container-labels-input-list -->
@@ -330,11 +398,11 @@
<form class="form-horizontal" style="margin-top: 15px;">
<!-- parallelism-input -->
<div class="form-group">
<label for="parallelism" class="col-sm-1 control-label text-left">Parallelism</label>
<div class="col-sm-1">
<label for="parallelism" class="col-sm-2 col-lg-1 control-label text-left">Parallelism</label>
<div class="col-sm-2">
<input type="number" class="form-control" ng-model="formValues.Parallelism" id="parallelism" placeholder="e.g. 1">
</div>
<div class="col-sm-10">
<div class="col-sm-8">
<p class="small text-muted" style="margin-top: 10px;">
Maximum number of tasks to be updated simultaneously (0 to update all at once).
</p>
@@ -343,11 +411,11 @@
<!-- !parallelism-input -->
<!-- delay-input -->
<div class="form-group">
<label for="update-delay" class="col-sm-1 control-label text-left">Delay</label>
<label for="update-delay" class="col-sm-2 col-lg-1 control-label text-left">Delay</label>
<div class="col-sm-2">
<input type="number" class="form-control" ng-model="formValues.UpdateDelay" id="update-delay" placeholder="e.g. 10">
</div>
<div class="col-sm-9">
<div class="col-sm-8">
<p class="small text-muted" style="margin-top: 10px;">
Amount of time between updates.
</p>
@@ -356,40 +424,20 @@
<!-- !delay-input -->
<!-- failureAction-input -->
<div class="form-group">
<label for="failure_action" class="col-sm-1 control-label text-left">Failure Action</label>
<div class="col-sm-3">
<label class="radio-inline">
<input type="radio" name="failure_action" ng-model="formValues.FailureAction" value="continue">
Continue
</label>
<label class="radio-inline">
<input type="radio" name="failure_action" ng-model="formValues.FailureAction" value="pause">
Pause
</label>
<div class="col-sm-12">
<label class="control-label text-left">Failure action</label>
<div class="btn-group btn-group-sm" style="margin-left: 20px;">
<label class="btn btn-primary" ng-model="formValues.FailureAction" uib-btn-radio="'continue'">Continue</label>
<label class="btn btn-primary" ng-model="formValues.FailureAction" uib-btn-radio="'pause'">Pause</label>
</div>
</div>
<div class="col-sm-8"></div>
</div>
<!-- !failureAction-input -->
</form>
</div>
<!-- !tab-update-config -->
<!-- tab-security -->
<div class="tab-pane" id="security">
</div>
<!-- !tab-security -->
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12" style="text-align: center;">
<div>
<i id="createServiceSpinner" class="fa fa-cog fa-3x fa-spin" style="margin-bottom: 5px; display: none;"></i>
</div>
<button type="button" class="btn btn-default btn-lg" ng-click="create()">Create</button>
<a type="button" class="btn btn-default btn-lg" ui-sref="services">Cancel</a>
</div>
</div>

View File

@@ -1,8 +1,9 @@
angular.module('createVolume', [])
.controller('CreateVolumeController', ['$scope', '$state', 'Volume', 'Messages',
function ($scope, $state, Volume, Messages) {
.controller('CreateVolumeController', ['$scope', '$state', 'Volume', 'ResourceControlService', 'Authentication', 'Messages',
function ($scope, $state, Volume, ResourceControlService, Authentication, Messages) {
$scope.formValues = {
Ownership: $scope.applicationState.application.authentication ? 'private' : '',
DriverOptions: []
};
@@ -25,9 +26,22 @@ function ($scope, $state, Volume, Messages) {
$('#createVolumeSpinner').hide();
Messages.error('Unable to create volume', {}, d.message);
} else {
Messages.send("Volume created", d.Name);
$('#createVolumeSpinner').hide();
$state.go('volumes', {}, {reload: true});
if ($scope.formValues.Ownership === 'private') {
ResourceControlService.setVolumeResourceControl(Authentication.getUserDetails().ID, d.Name)
.then(function success() {
Messages.send("Volume created", d.Name);
$('#createVolumeSpinner').hide();
$state.go('volumes', {}, {reload: true});
})
.catch(function error(err) {
$('#createVolumeSpinner').hide();
Messages.error("Failure", err, 'Unable to apply resource control on volume');
});
} else {
Messages.send("Volume created", d.Name);
$('#createVolumeSpinner').hide();
$state.go('volumes', {}, {reload: true});
}
}
}, function (e) {
$('#createVolumeSpinner').hide();

View File

@@ -18,6 +18,9 @@
</div>
</div>
<!-- !name-input -->
<div class="col-sm-12 form-section-title">
Driver configuration
</div>
<!-- driver-input -->
<div class="form-group">
<label for="volume_driver" class="col-sm-1 control-label text-left">Driver</label>
@@ -28,14 +31,17 @@
<!-- !driver-input -->
<!-- driver-options -->
<div class="form-group">
<label for="volume_driveropts" class="col-sm-1 control-label text-left">Driver options</label>
<div class="col-sm-11">
<span class="label label-default interactive" ng-click="addDriverOption()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> driver option
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">
Driver options
<portainer-tooltip position="bottom" message="Driver options are specific to the selected driver. Please refer to the selected driver documentation."></portainer-tooltip>
</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addDriverOption()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add driver option
</span>
</div>
<!-- driver-options-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="option in formValues.DriverOptions" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
@@ -44,29 +50,52 @@
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="option.value" placeholder="e.g. /path/on/host">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeDriverOption($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeDriverOption($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !driver-options-input-list -->
</div>
<!-- !driver-options -->
<div class="col-sm-12 form-section-title" ng-if="applicationState.application.authentication">
Access control
</div>
<!-- ownership -->
<div class="form-group" ng-if="applicationState.application.authentication">
<div class="col-sm-12">
<label for="ownership" class="control-label text-left">
Ownership
<portainer-tooltip position="bottom" message="When setting the ownership value to private, only you and the administrators will be able to see and manage this object. When choosing public, everybody will be able to access it."></portainer-tooltip>
</label>
<div class="btn-group btn-group-sm" style="margin-left: 20px;">
<label class="btn btn-primary" ng-model="formValues.Ownership" uib-btn-radio="'private'">
<i class="fa fa-eye-slash" aria-hidden="true"></i>
Private
</label>
<label class="btn btn-primary" ng-model="formValues.Ownership" uib-btn-radio="'public'">
<i class="fa fa-eye" aria-hidden="true"></i>
Public
</label>
</div>
</div>
</div>
<!-- !ownership -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-click="create()">Create volume</button>
<a type="button" class="btn btn-default btn-sm" ui-sref="volumes">Cancel</a>
<i id="createVolumeSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12" style="text-align: center;">
<div>
<i id="createVolumeSpinner" class="fa fa-cog fa-3x fa-spin" style="margin-bottom: 5px; display: none;"></i>
</div>
<button type="button" class="btn btn-default btn-lg" ng-click="create()">Create</button>
<a type="button" class="btn btn-default btn-lg" ui-sref="volumes">Cancel</a>
</div>
</div>

View File

@@ -85,7 +85,7 @@
</div>
<div class="row">
<div class="col-lg-6 col-md-6 col-sm-6 col-xs-12">
<div class="col-xs-12 col-md-6">
<a ui-sref="containers">
<rd-widget>
<rd-widget-body>
@@ -102,7 +102,7 @@
</rd-widget>
</a>
</div>
<div class="col-lg-6 col-md-6 col-sm-6 col-xs-12">
<div class="col-xs-12 col-md-6">
<a ui-sref="images">
<rd-widget>
<rd-widget-body>
@@ -118,7 +118,7 @@
</rd-widget>
</a>
</div>
<div class="col-lg-6 col-md-6 col-sm-6 col-xs-12">
<div class="col-xs-12 col-md-6">
<a ui-sref="volumes">
<rd-widget>
<rd-widget-body>
@@ -134,7 +134,7 @@
</rd-widget>
</a>
</div>
<div class="col-lg-6 col-md-6 col-sm-6 col-xs-12">
<div class="col-xs-12 col-md-6">
<a ui-sref="networks">
<rd-widget>
<rd-widget-body>

View File

@@ -14,25 +14,33 @@
<form class="form-horizontal">
<!-- name-input -->
<div class="form-group">
<label for="container_name" class="col-sm-2 control-label text-left">Name</label>
<div class="col-sm-10">
<label for="container_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="container_name" ng-model="endpoint.Name" placeholder="e.g. docker-prod01">
</div>
</div>
<!-- !name-input -->
<!-- endpoint-url-input -->
<div class="form-group">
<label for="endpoint_url" class="col-sm-2 control-label text-left">Endpoint URL</label>
<div class="col-sm-10">
<label for="endpoint_url" class="col-sm-3 col-lg-2 control-label text-left">
Endpoint URL
<portainer-tooltip position="bottom" message="URL or IP address of a Docker host. The Docker API must be exposed over a TCP port. Please refer to the Docker documentation to configure it."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input ng-disabled="endpointType === 'local'" type="text" class="form-control" id="endpoint_url" ng-model="endpoint.URL" placeholder="e.g. 10.0.0.10:2375 or mydocker.mydomain.com:2375">
</div>
</div>
<!-- !endpoint-url-input -->
<!-- tls-checkbox -->
<div class="form-group" ng-if="endpointType === 'remote'">
<label for="tls" class="col-sm-2 control-label text-left">TLS</label>
<div class="col-sm-10">
<input type="checkbox" name="tls" ng-model="endpoint.TLS">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
TLS
<portainer-tooltip position="bottom" message="Enable this option if you need to specify TLS certificates to connect to the Docker endpoint."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="endpoint.TLS"><i></i>
</label>
</div>
</div>
<!-- !tls-checkbox -->

View File

@@ -1,6 +1,11 @@
angular.module('endpoint', [])
.controller('EndpointController', ['$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'Messages',
function ($scope, $state, $stateParams, $filter, EndpointService, Messages) {
if (!$scope.applicationState.application.endpointManagement) {
$state.go('endpoints');
}
$scope.state = {
error: '',
uploadInProgress: false
@@ -13,14 +18,18 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Messages) {
$scope.updateEndpoint = function() {
var ID = $scope.endpoint.Id;
var name = $scope.endpoint.Name;
var URL = $scope.endpoint.URL;
var TLS = $scope.endpoint.TLS;
var TLSCACert = $scope.formValues.TLSCACert !== $scope.endpoint.TLSCACert ? $scope.formValues.TLSCACert : null;
var TLSCert = $scope.formValues.TLSCert !== $scope.endpoint.TLSCert ? $scope.formValues.TLSCert : null;
var TLSKey = $scope.formValues.TLSKey !== $scope.endpoint.TLSKey ? $scope.formValues.TLSKey : null;
var type = $scope.endpointType;
EndpointService.updateEndpoint(ID, name, URL, TLS, TLSCACert, TLSCert, TLSKey, type).then(function success(data) {
var endpointParams = {
name: $scope.endpoint.Name,
URL: $scope.endpoint.URL,
TLS: $scope.endpoint.TLS,
TLSCACert: $scope.formValues.TLSCACert !== $scope.endpoint.TLSCACert ? $scope.formValues.TLSCACert : null,
TLSCert: $scope.formValues.TLSCert !== $scope.endpoint.TLSCert ? $scope.formValues.TLSCert : null,
TLSKey: $scope.formValues.TLSKey !== $scope.endpoint.TLSKey ? $scope.formValues.TLSKey : null,
type: $scope.endpointType
};
EndpointService.updateEndpoint(ID, endpointParams)
.then(function success(data) {
Messages.send("Endpoint updated", $scope.endpoint.Name);
$state.go('endpoints');
}, function error(err) {

View File

@@ -0,0 +1,177 @@
<rd-header>
<rd-header-title title="Endpoint access">
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="endpoints">Endpoints</a> > <a ui-sref="endpoint({id: endpoint.Id})">{{ endpoint.Name }}</a> > Access management
</rd-header-content>
</rd-header>
<div class="row" ng-if="endpoint">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-plug" title="Endpoint"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Name</td>
<td>
{{ endpoint.Name }}
</td>
</tr>
<tr>
<td>URL</td>
<td>
{{ endpoint.URL | stripprotocol }}
</td>
</tr>
<tr>
<td colspan="2">
<span class="small text-muted">
You can select which user can access this endpoint by moving them to the authorized users table. Simply click
on a user entry to move it from one table to the other.
</span>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="endpoint">
<div class="col-sm-6">
<rd-widget>
<rd-widget-header classes="col-sm-12 col-md-6 nopadding" icon="fa-users" title="Users">
<div class="pull-md-right pull-lg-right">
Items per page:
<select ng-model="state.pagination_count_users" ng-change="changePaginationCountUsers()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-sm-12 nopadding">
<div class="col-sm-12 col-md-6 nopadding">
<button class="btn btn-primary btn-sm" ng-click="authorizeAllUsers()" ng-disabled="users.length === 0 || filteredUsers.length === 0"><i class="fa fa-user-plus space-right" aria-hidden="true"></i>Authorize all users</button>
</div>
<div class="col-sm-12 col-md-6 nopadding">
<input type="text" id="filter" ng-model="state.filterUsers" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<a ui-sref="endpoint.access({id: endpoint.Id})" ng-click="orderUsers('Username')">
Name
<span ng-show="sortTypeUsers == 'Username' && !sortReverseUsers" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortTypeUsers == 'Username' && sortReverseUsers" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="endpoint.access({id: endpoint.Id})" ng-click="orderUsers('Role')">
Role
<span ng-show="sortTypeUsers == 'Role' && !sortReverseUsers" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortTypeUsers == 'Role' && sortReverseUsers" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr ng-click="authorizeUser(user)" class="interactive" dir-paginate="user in (state.filteredUsers = (users | filter:state.filterUsers | orderBy:sortTypeUsers:sortReverseUsers | itemsPerPage: state.pagination_count_users))">
<td>{{ user.Username }}</td>
<td>
{{ user.RoleName }}
<i class="fa" ng-class="user.RoleId === 1 ? 'fa-user-circle-o' : 'fa-user'" aria-hidden="true" style="margin-left: 2px;"></i>
</td>
</tr>
<tr ng-if="!users">
<td colspan="2" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="users.length === 0 || state.filteredUsers.length === 0">
<td colspan="2" class="text-center text-muted">No users.</td>
</tr>
</tbody>
</table>
<div ng-if="users" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-sm-6">
<rd-widget>
<rd-widget-header classes="col-sm-12 col-md-6 nopadding" icon="fa-users" title="Authorized users">
<div class="pull-md-right pull-lg-right">
Items per page:
<select ng-model="state.pagination_count_authorizedUsers" ng-change="changePaginationCountAuthorizedUsers()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-sm-12 nopadding">
<div class="col-sm-12 col-md-6 nopadding">
<button class="btn btn-primary btn-sm" ng-click="unauthorizeAllUsers()" ng-disabled="authorizedUsers.length === 0 || filteredAuthorizedUsers.length === 0"><i class="fa fa-user-times space-right" aria-hidden="true"></i>Deny all users</button>
</div>
<div class="col-sm-12 col-md-6 nopadding">
<input type="text" id="filter" ng-model="state.filterAuthorizedUsers" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<a ui-sref="endpoint.access({id: endpoint.Id})" ng-click="orderAuthorizedUsers('Username')">
Name
<span ng-show="sortTypeAuthorizedUsers == 'Username' && !sortReverseAuthorizedUsers" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortTypeAuthorizedUsers == 'Username' && sortReverseAuthorizedUsers" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="endpoint.access({id: endpoint.Id})" ng-click="orderAuthorizedUsers('Role')">
Role
<span ng-show="sortTypeAuthorizedUsers == 'Role' && !sortReverseAuthorizedUsers" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortTypeAuthorizedUsers == 'Role' && sortReverseAuthorizedUsers" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr ng-click="unauthorizeUser(user)" class="interactive" dir-paginate="user in (state.filteredAuthorizedUsers = (authorizedUsers | filter:state.filterAuthorizedUsers | orderBy:sortTypeAuthorizedUsers:sortReverseAuthorizedUsers | itemsPerPage: state.pagination_count_authorizedUsers))">
<td>{{ user.Username }}</td>
<td>
{{ user.RoleName }}
<i class="fa" ng-class="user.RoleId === 1 ? 'fa-user-circle-o' : 'fa-user'" aria-hidden="true" style="margin-left: 2px;"></i>
</td>
</tr>
<tr ng-if="!authorizedUsers">
<td colspan="2" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="authorizedUsers.length === 0 || state.filteredAuthorizedUsers.length === 0">
<td colspan="2" class="text-center text-muted">No authorized users.</td>
</tr>
</tbody>
</table>
<div ng-if="authorizedUsers" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -0,0 +1,148 @@
angular.module('endpointAccess', [])
.controller('EndpointAccessController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'UserService', 'Pagination', 'Messages',
function ($q, $scope, $state, $stateParams, $filter, EndpointService, UserService, Pagination, Messages) {
$scope.state = {
pagination_count_users: Pagination.getPaginationCount('endpoint_access_users'),
pagination_count_authorizedUsers: Pagination.getPaginationCount('endpoint_access_authorizedUsers')
};
$scope.sortTypeUsers = 'Username';
$scope.sortReverseUsers = true;
$scope.orderUsers = function(sortType) {
$scope.sortReverseUsers = ($scope.sortTypeUsers === sortType) ? !$scope.sortReverseUsers : false;
$scope.sortTypeUsers = sortType;
};
$scope.changePaginationCountUsers = function() {
Pagination.setPaginationCount('endpoint_access_users', $scope.state.pagination_count_users);
};
$scope.sortTypeAuthorizedUsers = 'Username';
$scope.sortReverseAuthorizedUsers = true;
$scope.orderAuthorizedUsers = function(sortType) {
$scope.sortReverseAuthorizedUsers = ($scope.sortTypeAuthorizedUsers === sortType) ? !$scope.sortReverseAuthorizedUsers : false;
$scope.sortTypeAuthorizedUsers = sortType;
};
$scope.changePaginationCountAuthorizedUsers = function() {
Pagination.setPaginationCount('endpoint_access_authorizedUsers', $scope.state.pagination_count_authorizedUsers);
};
$scope.authorizeAllUsers = function() {
var authorizedUserIDs = [];
angular.forEach($scope.authorizedUsers, function (user) {
authorizedUserIDs.push(user.Id);
});
angular.forEach($scope.users, function (user) {
authorizedUserIDs.push(user.Id);
});
EndpointService.updateAuthorizedUsers($stateParams.id, authorizedUserIDs)
.then(function success(data) {
$scope.authorizedUsers = $scope.authorizedUsers.concat($scope.users);
$scope.users = [];
Messages.send('Access granted for all users');
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to update endpoint permissions");
});
};
$scope.unauthorizeAllUsers = function() {
EndpointService.updateAuthorizedUsers($stateParams.id, [])
.then(function success(data) {
$scope.users = $scope.users.concat($scope.authorizedUsers);
$scope.authorizedUsers = [];
Messages.send('Access removed for all users');
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to update endpoint permissions");
});
};
$scope.authorizeUser = function(user) {
var authorizedUserIDs = [];
angular.forEach($scope.authorizedUsers, function (u) {
authorizedUserIDs.push(u.Id);
});
authorizedUserIDs.push(user.Id);
EndpointService.updateAuthorizedUsers($stateParams.id, authorizedUserIDs)
.then(function success(data) {
removeUserFromArray(user.Id, $scope.users);
$scope.authorizedUsers.push(user);
Messages.send('Access granted for user', user.Username);
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to update endpoint permissions");
});
};
$scope.unauthorizeUser = function(user) {
var authorizedUserIDs = $scope.authorizedUsers.filter(function (u) {
if (u.Id !== user.Id) {
return u;
}
}).map(function (u) {
return u.Id;
});
EndpointService.updateAuthorizedUsers($stateParams.id, authorizedUserIDs)
.then(function success(data) {
removeUserFromArray(user.Id, $scope.authorizedUsers);
$scope.users.push(user);
Messages.send('Access removed for user', user.Username);
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to update endpoint permissions");
});
};
function getEndpointAndUsers(endpointID) {
$('#loadingViewSpinner').show();
$q.all({
endpoint: EndpointService.endpoint($stateParams.id),
users: UserService.users(),
})
.then(function success(data) {
$scope.endpoint = data.endpoint;
$scope.users = data.users.filter(function (user) {
if (user.Role !== 1) {
return user;
}
}).map(function (user) {
return new UserViewModel(user);
});
$scope.authorizedUsers = [];
angular.forEach($scope.endpoint.AuthorizedUsers, function(userID) {
for (var i = 0, l = $scope.users.length; i < l; i++) {
if ($scope.users[i].Id === userID) {
$scope.authorizedUsers.push($scope.users[i]);
$scope.users.splice(i, 1);
return;
}
}
});
})
.catch(function error(err) {
$scope.templates = [];
$scope.users = [];
$scope.authorizedUsers = [];
Messages.error("Failure", err, "Unable to retrieve endpoint details");
})
.finally(function final(){
$('#loadingViewSpinner').hide();
});
}
function removeUserFromArray(id, users) {
for (var i = 0, l = users.length; i < l; i++) {
if (users[i].Id === id) {
users.splice(i, 1);
return;
}
}
}
getEndpointAndUsers($stateParams.id);
}]);

View File

@@ -1,7 +1,7 @@
<div class="page-wrapper">
<!-- simple box -->
<div class="container simple-box">
<div class="col-md-8 col-md-offset-2 col-sm-8 col-sm-offset-2">
<div class="col-md-8 col-md-offset-2 col-sm-10 col-sm-offset-1">
<!-- simple box logo -->
<div class="row">
<img ng-if="logo" ng-src="{{ logo }}" class="simple-box-logo">
@@ -14,8 +14,8 @@
<!-- init-endpoint form -->
<form class="form-horizontal" style="margin: 20px;" enctype="multipart/form-data" method="POST">
<!-- comment -->
<div class="form-group">
<p>Connect Portainer to a Docker engine or Swarm cluster endpoint.</p>
<div class="form-group" style="text-align: center;">
<h4>Connect Portainer to a Docker engine or Swarm cluster endpoint</h4>
</div>
<!-- !comment input -->
<!-- endpoin-type radio -->
@@ -32,8 +32,8 @@
<div ng-if="formValues.endpointType === 'local'" style="margin-top: 25px;">
<div class="form-group">
<i class="fa fa-exclamation-triangle" aria-hidden="true" style="margin-right: 5px;"></i>
<span class="small text-primary">This feature is not available with Docker <b>on</b> Windows yet.</span>
<div class="small text-primary">On Linux / Mac, ensure that you have started Portainer container with the following Docker flag <code>-v "/var/run/docker.sock:/var/run/docker.sock"</code></div>
<span class="small text-primary">This feature is not yet available for native Docker Windows containers.</span>
<div class="small text-primary">On Linux and when using Docker for Mac or Docker for Windows or Docker Toolbox, ensure that you have started Portainer container with the following Docker flag <code>-v "/var/run/docker.sock:/var/run/docker.sock"</code></div>
</div>
<!-- connect button -->
<div class="form-group" style="margin-top: 10px;">
@@ -54,25 +54,33 @@
<div ng-if="formValues.endpointType === 'remote'" style="margin-top: 25px;">
<!-- name-input -->
<div class="form-group">
<label for="container_name" class="col-sm-3 control-label text-left">Name</label>
<div class="col-sm-9">
<label for="container_name" class="col-sm-4 col-lg-3 control-label text-left">Name</label>
<div class="col-sm-8 col-lg-9">
<input type="text" class="form-control" id="container_name" ng-model="formValues.Name" placeholder="e.g. docker-prod01">
</div>
</div>
<!-- !name-input -->
<!-- endpoint-url-input -->
<div class="form-group">
<label for="endpoint_url" class="col-sm-3 control-label text-left">Endpoint URL</label>
<div class="col-sm-9">
<label for="endpoint_url" class="col-sm-4 col-lg-3 control-label text-left">
Endpoint URL
<portainer-tooltip position="bottom" message="URL or IP address of a Docker host. The Docker API must be exposed over a TCP port. Please refer to the Docker documentation to configure it."></portainer-tooltip>
</label>
<div class="col-sm-8 col-lg-9">
<input type="text" class="form-control" id="endpoint_url" ng-model="formValues.URL" placeholder="e.g. 10.0.0.10:2375 or mydocker.mydomain.com:2375">
</div>
</div>
<!-- !endpoint-url-input -->
<!-- tls-checkbox -->
<div class="form-group">
<label for="tls" class="col-sm-3 control-label text-left">TLS</label>
<div class="col-sm-9">
<input type="checkbox" name="tls" ng-model="formValues.TLS">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
TLS
<portainer-tooltip position="bottom" message="Enable this option if you need to specify TLS certificates to connect to the Docker endpoint."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="formValues.TLS"><i></i>
</label>
</div>
</div>
<!-- !tls-checkbox -->

View File

@@ -1,6 +1,6 @@
angular.module('endpointInit', [])
.controller('EndpointInitController', ['$scope', '$state', 'EndpointService', 'StateManager', 'Messages',
function ($scope, $state, EndpointService, StateManager, Messages) {
.controller('EndpointInitController', ['$scope', '$state', 'EndpointService', 'StateManager', 'EndpointProvider', 'Messages',
function ($scope, $state, EndpointService, StateManager, EndpointProvider, Messages) {
$scope.state = {
error: '',
uploadInProgress: false
@@ -29,20 +29,28 @@ function ($scope, $state, EndpointService, StateManager, Messages) {
var name = "local";
var URL = "unix:///var/run/docker.sock";
var TLS = false;
EndpointService.createLocalEndpoint(name, URL, TLS, true).then(function success(data) {
StateManager.updateEndpointState(false)
.then(function success() {
EndpointService.createLocalEndpoint(name, URL, TLS, true)
.then(
function success(data) {
var endpointID = data.Id;
EndpointProvider.setEndpointID(endpointID);
StateManager.updateEndpointState(false).then(
function success() {
$state.go('dashboard');
}, function error(err) {
EndpointService.deleteEndpoint(0)
},
function error(err) {
EndpointService.deleteEndpoint(endpointID)
.then(function success() {
$('#initEndpointSpinner').hide();
$scope.state.error = 'Unable to connect to the Docker endpoint';
});
});
}, function error(err) {
$('#initEndpointSpinner').hide();
},
function error() {
$scope.state.error = 'Unable to create endpoint';
})
.finally(function final() {
$('#initEndpointSpinner').hide();
});
};
@@ -57,11 +65,13 @@ function ($scope, $state, EndpointService, StateManager, Messages) {
var TLSKeyFile = $scope.formValues.TLSKey;
EndpointService.createRemoteEndpoint(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, TLS ? false : true)
.then(function success(data) {
var endpointID = data.Id;
EndpointProvider.setEndpointID(endpointID);
StateManager.updateEndpointState(false)
.then(function success() {
$state.go('dashboard');
}, function error(err) {
EndpointService.deleteEndpoint(0)
EndpointService.deleteEndpoint(endpointID)
.then(function success() {
$('#initEndpointSpinner').hide();
$scope.state.error = 'Unable to connect to the Docker endpoint';

View File

@@ -8,7 +8,19 @@
<rd-header-content>Endpoint management</rd-header-content>
</rd-header>
<div class="row">
<div class="row" ng-if="!applicationState.application.endpointManagement">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-exclamation-triangle" title="Endpoint management is not available">
</rd-widget-header>
<rd-widget-body>
<span class="small text-muted">Portainer has been started using the <code>--external-endpoints</code> flag. Endpoint management via the UI is disabled. You can still manage endpoint access.</span>
</rd-wigdet-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="applicationState.application.endpointManagement">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-plus" title="Add a new endpoint">
@@ -17,25 +29,33 @@
<form class="form-horizontal">
<!-- name-input -->
<div class="form-group">
<label for="container_name" class="col-sm-2 control-label text-left">Name</label>
<div class="col-sm-10">
<label for="container_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="container_name" ng-model="formValues.Name" placeholder="e.g. docker-prod01">
</div>
</div>
<!-- !name-input -->
<!-- endpoint-url-input -->
<div class="form-group">
<label for="endpoint_url" class="col-sm-2 control-label text-left">Endpoint URL</label>
<div class="col-sm-10">
<label for="endpoint_url" class="col-sm-3 col-lg-2 control-label text-left">
Endpoint URL
<portainer-tooltip position="bottom" message="URL or IP address of a Docker host. The Docker API must be exposed over a TCP port. Please refer to the Docker documentation to configure it."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="endpoint_url" ng-model="formValues.URL" placeholder="e.g. 10.0.0.10:2375 or mydocker.mydomain.com:2375">
</div>
</div>
<!-- !endpoint-url-input -->
<!-- tls-checkbox -->
<div class="form-group">
<label for="tls" class="col-sm-2 control-label text-left">TLS</label>
<div class="col-sm-10">
<input type="checkbox" name="tls" ng-model="formValues.TLS">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
TLS
<portainer-tooltip position="bottom" message="Enable this option if you need to specify TLS certificates to connect to the Docker endpoint."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="formValues.TLS"><i></i>
</label>
</div>
</div>
<!-- !tls-checkbox -->
@@ -113,7 +133,7 @@
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-lg-12">
<div class="pull-left">
<div class="pull-left" ng-if="applicationState.application.endpointManagement">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
</div>
<div class="pull-right">
@@ -125,7 +145,9 @@
<table class="table table-hover">
<thead>
<tr>
<th></th>
<th ng-if="applicationState.application.endpointManagement">
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ui-sref="endpoints" ng-click="order('Name')">
Name
@@ -140,28 +162,25 @@
<span ng-show="sortType == 'URL' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="endpoints" ng-click="order('TLS')">
TLS
<span ng-show="sortType == 'TLS' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'TLS' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th></th>
</tr>
</thead>
<tbody>
<tr dir-paginate="endpoint in (state.filteredEndpoints = (endpoints | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="endpoint.Checked" ng-change="selectItem(endpoint)" /></td>
<td ng-if="applicationState.application.endpointManagement"><input type="checkbox" ng-model="endpoint.Checked" ng-change="selectItem(endpoint)" /></td>
<td><i class="fa fa-star" aria-hidden="true" ng-if="endpoint.Id === activeEndpoint.Id"></i> {{ endpoint.Name }}</td>
<td>{{ endpoint.URL | stripprotocol }}</td>
<td><i class="fa fa-shield" aria-hidden="true" ng-if="endpoint.TLS"></i></td>
<td>
<span ng-if="endpoint.Id !== activeEndpoint.Id">
<a ui-sref="endpoint({id: endpoint.Id})"><i class="fa fa-pencil-square-o" aria-hidden="true"></i> Edit</a>
<span ng-if="applicationState.application.endpointManagement">
<span ng-if="endpoint.Id !== activeEndpointID">
<a ui-sref="endpoint({id: endpoint.Id})"><i class="fa fa-pencil-square-o" aria-hidden="true"></i> Edit</a>
</span>
<span class="small text-muted" ng-if="endpoint.Id === activeEndpointID">
<i class="fa fa-lock" aria-hidden="true"></i> You cannot edit the active endpoint
</span>
</span>
<span class="small text-muted" ng-if="endpoint.Id === activeEndpoint.Id">
<i class="fa fa-lock" aria-hidden="true"></i> You cannot edit the active endpoint
<span ng-if="applicationState.application.authentication">
<a ui-sref="endpoint.access({id: endpoint.Id})"><i class="fa fa-users" aria-hidden="true" style="margin-left: 7px;"></i> Manage access</a>
</span>
</td>
</tr>

View File

@@ -1,6 +1,6 @@
angular.module('endpoints', [])
.controller('EndpointsController', ['$scope', '$state', 'EndpointService', 'Messages', 'Pagination',
function ($scope, $state, EndpointService, Messages, Pagination) {
.controller('EndpointsController', ['$scope', '$state', 'EndpointService', 'EndpointProvider', 'Messages', 'Pagination',
function ($scope, $state, EndpointService, EndpointProvider, Messages, Pagination) {
$scope.state = {
error: '',
uploadInProgress: false,
@@ -28,6 +28,15 @@ function ($scope, $state, EndpointService, Messages, Pagination) {
Pagination.setPaginationCount('endpoints', $scope.state.pagination_count);
};
$scope.selectItems = function (allSelected) {
angular.forEach($scope.state.filteredEndpoints, function (endpoint) {
if (endpoint.Checked !== allSelected) {
endpoint.Checked = allSelected;
$scope.selectItem(endpoint);
}
});
};
$scope.selectItem = function (item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
@@ -84,19 +93,17 @@ function ($scope, $state, EndpointService, Messages, Pagination) {
function fetchEndpoints() {
$('#loadEndpointsSpinner').show();
EndpointService.endpoints().then(function success(data) {
EndpointService.endpoints()
.then(function success(data) {
$scope.endpoints = data;
EndpointService.getActive().then(function success(data) {
$scope.activeEndpoint = data;
$('#loadEndpointsSpinner').hide();
}, function error(err) {
$('#loadEndpointsSpinner').hide();
Messages.error("Failure", err, "Unable to retrieve active endpoint");
});
}, function error(err) {
$('#loadEndpointsSpinner').hide();
$scope.activeEndpointID = EndpointProvider.endpointID();
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to retrieve endpoints");
$scope.endpoints = [];
})
.finally(function final() {
$('#loadEndpointsSpinner').hide();
});
}

View File

@@ -7,28 +7,42 @@
</rd-header-content>
</rd-header>
<div class="row" ng-if="RepoTags.length > 0">
<div class="row" ng-if="image.RepoTags.length > 0">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa fa-tags" title="Image tags"></rd-widget-header>
<rd-widget-body classes="no-padding">
<div style="margin: 5px 10px;">
<span ng-repeat="tag in RepoTags" class="label label-primary image-tag space-right">
<a data-toggle="tooltip" class="interactive" title="Push to registry" ng-click="pushImage(tag)">
<i class="fa fa-upload white-icon" aria-hidden="true"></i>
</a>
{{ tag }}
<a data-toggle="tooltip" class="interactive" title="Remove tag" ng-click="removeImage(tag)">
<i class="fa fa-trash-o white-icon" aria-hidden="true"></i>
</a>
</span>
</div>
<div style="margin: 5px 10px;">
<span class="small text-muted">
Note: you can click on the upload icon to push an image
and on the trash icon to delete a tag
</span>
</div>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="row">
<div class="pull-left" ng-repeat="tag in image.RepoTags" style="display:table">
<div class="input-group col-md-1" style="padding:0 15px">
<span class="input-group-addon">{{ tag }}</span>
<span class="input-group-btn">
<a data-toggle="tooltip" class="btn btn-primary interactive" title="Push to registry" ng-click="pushImage(tag)">
<span class="fa fa-upload white-icon" aria-hidden="true"></span>
</a>
<a data-toggle="tooltip" class="btn btn-primary interactive" title="Pull from registry" ng-click="pullImage(tag)">
<span class="fa fa-download white-icon" aria-hidden="true"></span>
</a>
<a data-toggle="tooltip" class="btn btn-primary interactive" title="Remove tag" ng-click="removeTag(tag)">
<span class="fa fa-trash-o white-icon" aria-hidden="true"></span>
</a>
</span>
</div>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted">
Note: you can click on the upload icon <span class="fa fa-upload" aria-hidden="true"></span> to push an image
or on the download icon <span class="fa fa-download" aria-hidden="true"></span> to pull an image
or on the trash icon <span class="fa fa-trash-o" aria-hidden="true"></span> to delete a tag.
</span>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
@@ -43,12 +57,15 @@
<!-- name-and-registry-inputs -->
<div class="form-group">
<label for="image_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-7">
<div class="col-sm-11 col-md-6">
<input type="text" class="form-control" ng-model="config.Image" id="image_name" placeholder="e.g. myImage:myTag">
</div>
<label for="image_registry" class="col-sm-1 control-label text-left">Registry</label>
<div class="col-sm-3">
<input type="text" class="form-control" ng-model="config.Registry" id="image_registry" placeholder="optional">
<label for="image_registry" class="col-sm-2 margin-sm-top control-label text-left">
Registry
<portainer-tooltip position="bottom" message="A registry to pull the image from. Leave empty to use the official Docker registry."></portainer-tooltip>
</label>
<div class="col-sm-10 col-md-3 margin-sm-top">
<input type="text" class="form-control" ng-model="config.Registry" id="image_registry" placeholder="e.g. myregistry.mydomain">
</div>
</div>
<!-- !name-and-registry-inputs -->
@@ -61,7 +78,7 @@
<!-- !tag-note -->
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-default btn-sm" ng-disabled="!config.Image" ng-click="tagImage()">Tag</button>
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Image" ng-click="tagImage()">Tag</button>
<i id="pullImageSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
@@ -121,33 +138,33 @@
<tbody>
<tr>
<td>CMD</td>
<td><code>{{ image.ContainerConfig.Cmd|command }}</code></td>
<td><code>{{ image.Command|command }}</code></td>
</tr>
<tr ng-if="image.ContainerConfig.Entrypoint">
<tr ng-if="image.Entrypoint">
<td>ENTRYPOINT</td>
<td><code>{{ image.ContainerConfig.Entrypoint|command }}</code></td>
<td><code>{{ image.Entrypoint|command }}</code></td>
</tr>
<tr ng-if="image.ContainerConfig.ExposedPorts">
<tr ng-if="image.ExposedPorts.length > 0">
<td>EXPOSE</td>
<td>
<span class="label label-default space-right" ng-repeat="port in exposedPorts">
<span class="label label-default space-right" ng-repeat="port in image.ExposedPorts">
{{ port }}
</span>
</td>
</tr>
<tr ng-if="image.ContainerConfig.Volumes">
<tr ng-if="image.Volumes.length > 0">
<td>VOLUME</td>
<td>
<span class="label label-default space-right" ng-repeat="volume in volumes">
<span class="label label-default space-right" ng-repeat="volume in image.Volumes">
{{ volume }}
</span>
</td>
</tr>
<tr>
<tr ng-if="image.Env.length > 0">
<td>ENV</td>
<td>
<table class="table table-bordered table-condensed">
<tr ng-repeat="var in image.ContainerConfig.Env">
<tr ng-repeat="var in image.Env">
<td>{{ var|key: '=' }}</td>
<td>{{ var|value: '=' }}</td>
</tr>

View File

@@ -1,89 +1,109 @@
angular.module('image', [])
.controller('ImageController', ['$scope', '$stateParams', '$state', 'Image', 'ImageHelper', 'Messages',
function ($scope, $stateParams, $state, Image, ImageHelper, Messages) {
$scope.RepoTags = [];
$scope.config = {
Image: '',
Registry: ''
};
.controller('ImageController', ['$scope', '$stateParams', '$state', 'ImageService', 'Messages',
function ($scope, $stateParams, $state, ImageService, Messages) {
$scope.config = {
Image: '',
Registry: ''
};
// Get RepoTags from the /images/query endpoint instead of /image/json,
// for backwards compatibility with Docker API versions older than 1.21
function getRepoTags(imageId) {
Image.query({}, function (d) {
d.forEach(function(image) {
if (image.Id === imageId && image.RepoTags[0] !== '<none>:<none>') {
$scope.RepoTags = image.RepoTags;
}
});
});
}
$scope.tagImage = function() {
$('#loadingViewSpinner').show();
var image = $scope.config.Image;
var registry = $scope.config.Registry;
$scope.tagImage = function() {
$('#loadingViewSpinner').show();
var image = $scope.config.Image;
var registry = $scope.config.Registry;
var imageConfig = ImageHelper.createImageConfigForCommit(image, registry);
Image.tag({id: $stateParams.id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) {
Messages.send('Image successfully tagged');
$('#loadingViewSpinner').hide();
$state.go('image', {id: $stateParams.id}, {reload: true});
}, function(e) {
$('#loadingViewSpinner').hide();
Messages.error("Failure", e, "Unable to tag image");
});
};
ImageService.tagImage($stateParams.id, image, registry)
.then(function success(data) {
Messages.send('Image successfully tagged');
$state.go('image', {id: $stateParams.id}, {reload: true});
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to tag image");
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
};
$scope.pushImage = function(tag) {
$('#loadingViewSpinner').show();
Image.push({tag: tag}, function (d) {
if (d[d.length-1].error) {
Messages.error("Unable to push image", {}, d[d.length-1].error);
} else {
Messages.send('Image successfully pushed');
}
$('#loadingViewSpinner').hide();
}, function (e) {
$('#loadingViewSpinner').hide();
Messages.error("Failure", e, "Unable to push image");
});
};
$scope.pushImage = function(tag) {
$('#loadingViewSpinner').show();
ImageService.pushImage(tag)
.then(function success() {
Messages.send('Image successfully pushed');
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to push image tag");
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
};
$scope.removeImage = function (id) {
$('#loadingViewSpinner').show();
Image.remove({id: id}, function (d) {
if (d[0].message) {
$('#loadingViewSpinner').hide();
Messages.error("Unable to remove image", {}, d[0].message);
} else {
// If last message key is 'Deleted' or if it's 'Untagged' and there is only one tag associated to the image
// then assume the image is gone and send to images page
if (d[d.length-1].Deleted || (d[d.length-1].Untagged && $scope.RepoTags.length === 1)) {
Messages.send('Image successfully deleted');
$state.go('images', {}, {reload: true});
} else {
Messages.send('Tag successfully deleted');
$state.go('image', {id: $stateParams.id}, {reload: true});
}
}
}, function (e) {
$('#loadingViewSpinner').hide();
Messages.error("Failure", e, 'Unable to remove image');
});
};
$scope.pullImage = function(tag) {
$('#loadingViewSpinner').show();
var image = $scope.config.Image;
var registry = $scope.config.Registry;
$('#loadingViewSpinner').show();
Image.get({id: $stateParams.id}, function (d) {
$scope.image = d;
if (d.RepoTags) {
$scope.RepoTags = d.RepoTags;
} else {
getRepoTags(d.Id);
}
$('#loadingViewSpinner').hide();
$scope.exposedPorts = d.ContainerConfig.ExposedPorts ? Object.keys(d.ContainerConfig.ExposedPorts) : [];
$scope.volumes = d.ContainerConfig.Volumes ? Object.keys(d.ContainerConfig.Volumes) : [];
}, function (e) {
Messages.error("Failure", e, "Unable to retrieve image info");
});
ImageService.pullImage(image, registry)
.then(function success(data) {
Messages.send('Image successfully pulled', image);
})
.catch(function error(err){
Messages.error("Failure", err, "Unable to pull image");
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
};
$scope.removeTag = function(id) {
$('#loadingViewSpinner').show();
ImageService.deleteImage(id, false)
.then(function success() {
if ($scope.image.RepoTags.length === 1) {
Messages.send('Image successfully deleted', id);
$state.go('images', {}, {reload: true});
} else {
Messages.send('Tag successfully deleted', id);
$state.go('image', {id: $stateParams.id}, {reload: true});
}
})
.catch(function error(err) {
Messages.error("Failure", err, 'Unable to remove image');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
};
$scope.removeImage = function (id) {
$('#loadingViewSpinner').show();
ImageService.deleteImage(id, false)
.then(function success() {
Messages.send('Image successfully deleted', id);
$state.go('images', {}, {reload: true});
})
.catch(function error(err) {
Messages.error("Failure", err, 'Unable to remove image');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
};
function retrieveImageDetails() {
$('#loadingViewSpinner').show();
ImageService.image($stateParams.id)
.then(function success(data) {
$scope.image = data;
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to retrieve image details");
$state.go('images');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
retrieveImageDetails();
}]);

View File

@@ -18,12 +18,15 @@
<!-- name-and-registry-inputs -->
<div class="form-group">
<label for="image_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-7">
<div class="col-sm-11 col-md-6">
<input type="text" class="form-control" ng-model="config.Image" id="image_name" placeholder="e.g. ubuntu:trusty">
</div>
<label for="image_registry" class="col-sm-1 control-label text-left">Registry</label>
<div class="col-sm-3">
<input type="text" class="form-control" ng-model="config.Registry" id="image_registry" placeholder="leave empty to use DockerHub">
<label for="image_registry" class="col-sm-2 margin-sm-top control-label text-left">
Registry
<portainer-tooltip position="bottom" message="A registry to pull the image from. Leave empty to use the official Docker registry."></portainer-tooltip>
</label>
<div class="col-sm-10 col-md-3 margin-sm-top">
<input type="text" class="form-control" ng-model="config.Registry" id="image_registry" placeholder="e.g. myregistry.mydomain">
</div>
</div>
<!-- !name-and-registry-inputs -->
@@ -36,7 +39,7 @@
<!-- !tag-note -->
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-default btn-sm" ng-disabled="!config.Image" ng-click="pullImage()">Pull</button>
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Image" ng-click="pullImage()">Pull</button>
<i id="pullImageSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
@@ -63,7 +66,16 @@
</rd-widget-header>
<rd-widget-taskbar classes="col-lg-12">
<div class="pull-left">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<div class="btn-group">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" ng-disabled="!state.selectedItemCount" >
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="confirmRemovalAction(true)" ng-disabled="!state.selectedItemCount" >Force Remove</a></li>
</ul>
</div>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />

View File

@@ -1,6 +1,6 @@
angular.module('images', [])
.controller('ImagesController', ['$scope', '$state', 'Config', 'Image', 'ImageHelper', 'Messages', 'Pagination',
function ($scope, $state, Config, Image, ImageHelper, Messages, Pagination) {
.controller('ImagesController', ['$scope', '$state', 'Config', 'ImageService', 'Messages', 'Pagination', 'ModalService',
function ($scope, $state, Config, ImageService, Messages, Pagination, ModalService) {
$scope.state = {};
$scope.state.pagination_count = Pagination.getPaginationCount('images');
$scope.sortType = 'RepoTags';
@@ -42,24 +42,27 @@ function ($scope, $state, Config, Image, ImageHelper, Messages, Pagination) {
$('#pullImageSpinner').show();
var image = $scope.config.Image;
var registry = $scope.config.Registry;
var imageConfig = ImageHelper.createImageConfigForContainer(image, registry);
Image.create(imageConfig, function (data) {
var err = data.length > 0 && data[data.length - 1].hasOwnProperty('error');
if (err) {
var detail = data[data.length - 1];
$('#pullImageSpinner').hide();
Messages.error('Error', {}, detail.error);
} else {
$('#pullImageSpinner').hide();
$state.reload();
}
}, function (e) {
ImageService.pullImage(image, registry)
.then(function success(data) {
$state.reload();
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to pull image");
})
.finally(function final() {
$('#pullImageSpinner').hide();
Messages.error("Failure", e, "Unable to pull image");
});
};
$scope.removeAction = function () {
$scope.confirmRemovalAction = function (force) {
ModalService.confirmImageForceRemoval(function (confirmed) {
if(!confirmed) { return; }
$scope.removeAction(force);
});
};
$scope.removeAction = function (force) {
force = !!force;
$('#loadImagesSpinner').show();
var counter = 0;
var complete = function () {
@@ -71,18 +74,16 @@ function ($scope, $state, Config, Image, ImageHelper, Messages, Pagination) {
angular.forEach($scope.images, function (i) {
if (i.Checked) {
counter = counter + 1;
Image.remove({id: i.Id}, function (d) {
if (d[0].message) {
$('#loadImagesSpinner').hide();
Messages.error("Unable to remove image", {}, d[0].message);
} else {
Messages.send("Image deleted", i.Id);
var index = $scope.images.indexOf(i);
$scope.images.splice(index, 1);
}
complete();
}, function (e) {
Messages.error("Failure", e, 'Unable to remove image');
ImageService.deleteImage(i.Id, force)
.then(function success(data) {
Messages.send("Image deleted", i.Id);
var index = $scope.images.indexOf(i);
$scope.images.splice(index, 1);
})
.catch(function error(err) {
Messages.error("Failure", err, 'Unable to remove image');
})
.finally(function final() {
complete();
});
}
@@ -90,19 +91,19 @@ function ($scope, $state, Config, Image, ImageHelper, Messages, Pagination) {
};
function fetchImages() {
Image.query({}, function (d) {
$scope.images = d.map(function (item) {
return new ImageViewModel(item);
});
$('#loadImagesSpinner').hide();
}, function (e) {
$('#loadImagesSpinner').hide();
Messages.error("Failure", e, "Unable to retrieve images");
$('#loadImagesSpinner').show();
ImageService.images()
.then(function success(data) {
$scope.images = data;
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to retrieve images");
$scope.images = [];
})
.finally(function final() {
$('#loadImagesSpinner').hide();
});
}
Config.$promise.then(function (c) {
fetchImages();
});
fetchImages();
}]);

View File

@@ -37,8 +37,8 @@
<!-- !tag-note -->
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-default btn-sm" ng-disabled="!config.Name" ng-click="createNetwork()">Create</button>
<button type="button" class="btn btn-default btn-sm" ui-sref="actions.create.network">Advanced settings...</button>
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Name" ng-click="createNetwork()">Create</button>
<button type="button" class="btn btn-primary btn-sm" ui-sref="actions.create.network">Advanced settings...</button>
<i id="createNetworkSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>

View File

@@ -177,8 +177,8 @@
<span class="input-group-addon fit-text-size">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" ng-change="updateLabel(node, label)">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeLabel(node, $index)">
<i class="fa fa-minus" aria-hidden="true"></i>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeLabel(node, $index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</span>
</div>

View File

@@ -0,0 +1,66 @@
<div ng-if="service.ServiceConstraints">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Placement constraints">
<div class="nopadding">
<a class="btn btn-default btn-sm pull-right" ng-click="isUpdating || addPlacementConstraint(service)" ng-disabled="isUpdating">
<i class="fa fa-plus-circle" aria-hidden="true"></i> placement constraint
</a>
</div>
</rd-widget-header>
<rd-widget-body ng-if="service.ServiceConstraints.length === 0">
<p>There are no placement constraints for this service.</p>
</rd-widget-body>
<rd-widget-body ng-if="service.ServiceConstraints.length > 0" classes="no-padding">
<table class="table" >
<thead>
<tr>
<th>Name</th>
<th>Operator</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="constraint in service.ServiceConstraints">
<td>
<div class="input-group input-group-sm">
<input type="text" class="form-control" ng-model="constraint.key" placeholder="e.g. node.role" ng-change="updatePlacementConstraint(service, constraint)" ng-disabled="isUpdating">
</div>
</td>
<td>
<div class="input-group input-group-sm">
<select name="constraintOperator" class="form-control" ng-model="constraint.operator" ng-change="updatePlacementConstraint(service, constraint)" ng-disabled="isUpdating">
<option value="==">==</option>
<option value="!=">!=</option>
</select>
</div>
</td>
<td>
<div class="input-group input-group-sm">
<input type="text" class="form-control" ng-model="constraint.value" placeholder="e.g. manager" ng-change="updatePlacementConstraint(service, constraint)" ng-disabled="isUpdating">
<span class="input-group-btn">
<button class="btn btn-sm btn-danger" type="button" ng-click="removePlacementConstraint(service, $index)" ng-disabled="isUpdating">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServiceConstraints'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceConstraints'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</div>

View File

@@ -0,0 +1,56 @@
<div>
<rd-widget>
<rd-widget-header icon="fa-list-alt" title="Container spec"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>CMD</td>
<td><code ng-if="service.Command">{{ service.Command|command }}</code></td>
<td>
<p class="small text-muted" style="margin-top: 10px;">
Command to execute.
</p>
</td>
</tr>
<tr>
<td>Args</td>
<td><code ng-if="service.Arguments">{{ service.Arguments }}</code></td>
<td>
<p class="small text-muted" style="margin-top: 10px;">
Arguments passed to command in container.
</p>
</td>
</tr>
<tr>
<td>User</td>
<td>{{ service.User }}</td>
<td>
<p class="small text-muted" style="margin-top: 10px;">
Username or UID.
</p>
</td>
</tr>
<tr>
<td>Working directory</td>
<td>{{ service.Dir }}</td>
<td>
<p class="small text-muted" style="margin-top: 10px;">
Working directory inside the container.
</p>
</td>
</tr>
<tr>
<td>Stop grace period</td>
<td>{{ service.StopGracePeriod }}</td>
<td>
<p class="small text-muted" style="margin-top: 10px;">
Time to wait before force killing a container (default none).
</p>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>

View File

@@ -0,0 +1,59 @@
<div>
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Container labels">
<div class="nopadding">
<a class="btn btn-default btn-sm pull-right" ng-click="isUpdating ||addContainerLabel(service)" ng-disabled="isUpdating">
<i class="fa fa-plus-circle" aria-hidden="true"></i> container label
</a>
</div>
</rd-widget-header>
<rd-widget-body ng-if="service.ServiceContainerLabels.length === 0">
<p>There are no container labels for this service.</p>
</rd-widget-body>
<rd-widget-body ng-if="service.ServiceContainerLabels.length > 0" classes="no-padding">
<table class="table" >
<thead>
<tr>
<th>Label</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="label in service.ServiceContainerLabels">
<td>
<div class="input-group input-group-sm">
<span class="input-group-addon fit-text-size">name</span>
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" ng-change="updateContainerLabel(service, label)" ng-disabled="isUpdating">
</div>
</td>
<td>
<div class="input-group input-group-sm">
<span class="input-group-addon fit-text-size">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" ng-change="updateContainerLabel(service, label)" ng-disabled="isUpdating">
<span class="input-group-btn">
<button class="btn btn-sm btn-danger" type="button" ng-click="removeContainerLabel(service, $index)" ng-disabled="isUpdating">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServiceContainerLabels'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceContainerLabels'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</div>

View File

@@ -0,0 +1,59 @@
<div ng-if="service.EnvironmentVariables">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Environment variables">
<div class="nopadding">
<a class="btn btn-default btn-sm pull-right" ng-click="isUpdating ||addEnvironmentVariable(service)" ng-disabled="isUpdating">
<i class="fa fa-plus-circle" aria-hidden="true"></i> environment variable
</a>
</div>
</rd-widget-header>
<rd-widget-body ng-if="service.EnvironmentVariables.length === 0">
<p>There are no environment variables for this service.</p>
</rd-widget-body>
<rd-widget-body ng-if="service.EnvironmentVariables.length > 0" classes="no-padding">
<table class="table" >
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="var in service.EnvironmentVariables">
<td>
<div class="input-group input-group-sm">
<span class="input-group-addon fit-text-size">name</span>
<input type="text" class="form-control" ng-model="var.key" ng-disabled="var.added || isUpdating" placeholder="e.g. FOO">
</div>
</td>
<td>
<div class="input-group input-group-sm">
<span class="input-group-addon fit-text-size">value</span>
<input type="text" class="form-control" ng-model="var.value" ng-change="updateEnvironmentVariable(service, var)" placeholder="e.g. bar" ng-disabled="isUpdating">
<span class="input-group-btn">
<button class="btn btn-sm btn-danger" type="button" ng-click="removeEnvironmentVariable(service, $index)" ng-disabled="isUpdating">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['EnvironmentVariables'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['EnvironmentVariables'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</div>

View File

@@ -0,0 +1,67 @@
<div ng-if="service.ServiceMounts">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Mounts">
<div class="nopadding">
<a class="btn btn-default btn-sm pull-right" ng-click="isUpdating ||addMount(service)" ng-disabled="isUpdating">
<i class="fa fa-plus-circle" aria-hidden="true"></i> mount
</a>
</div>
</rd-widget-header>
<rd-widget-body ng-if="service.ServiceMounts.length === 0">
<p>There are no mounts for this service.</p>
</rd-widget-body>
<rd-widget-body ng-if="service.ServiceMounts.length > 0" classes="no-padding">
<table class="table" >
<thead>
<tr>
<th>Type</th>
<th>Source</th>
<th>Target</th>
<th>Read only</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="mount in service.ServiceMounts">
<td>
<select name="mountType" class="form-control" ng-model="mount.Type" ng-disabled="isUpdating">
<option value="volume">Volume</option>
<option value="bind">Bind</option>
</select>
</td>
<td>
<input type="text" class="form-control" ng-model="mount.Source" placeholder="e.g. /tmp/portainer/data" ng-change="updateMount(service, mount)" ng-disabled="isUpdating">
</td>
<td>
<input type="text" class="form-control" ng-model="mount.Target" placeholder="e.g. /tmp/portainer/data" ng-change="updateMount(service, mount)" ng-disabled="isUpdating">
</td>
<td>
<input type="checkbox" class="form-control" ng-model="mount.ReadOnly" ng-change="updateMount(service, mount)" ng-disabled="isUpdating">
</td>
<td>
<span class="input-group-btn">
<button class="btn btn-sm btn-danger" type="button" ng-click="removeMount(service, $index)" ng-disabled="isUpdating">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</span>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServiceMounts'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceMounts'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</div>

View File

@@ -0,0 +1,26 @@
<div>
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Networks"></rd-widget-header>
<rd-widget-body ng-if="!service.VirtualIPs || service.VirtualIPs.length === 0">
<p>This service is not connected to any networks.</p>
</rd-widget-body>
<rd-widget-body ng-if="service.VirtualIPs && service.VirtualIPs.length > 0" classes="no-padding">
<table class="table" >
<thead>
<tr>
<th>ID</th>
<th>IP address</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="network in service.VirtualIPs">
<td>
<a ui-sref="network({id: network.NetworkID})">{{ network.NetworkID }}</a>
</td>
<td>{{ network.Addr }}</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>

View File

@@ -0,0 +1,71 @@
<div>
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Published ports">
<div class="nopadding">
<a class="btn btn-default btn-sm pull-right" ng-click="isUpdating ||addPublishedPort(service)" ng-disabled="isUpdating">
<i class="fa fa-plus-circle" aria-hidden="true"></i> port mapping
</a>
</div>
</rd-widget-header>
<rd-widget-body ng-if="!service.Ports || service.Ports.length === 0">
<p>This service has no ports published.</p>
</rd-widget-body>
<rd-widget-body ng-if="service.Ports && service.Ports.length > 0" classes="no-padding">
<table class="table" >
<thead>
<tr>
<th>Host port</th>
<th>Container port</th>
<th>Protocol</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="portBinding in service.Ports">
<td>
<div class="input-group input-group-sm">
<span class="input-group-addon">host</span>
<input type="number" class="form-control" ng-model="portBinding.PublishedPort" placeholder="e.g. 8080" ng-change="updatePublishedPort(service, mapping)" ng-disabled="isUpdating">
</div>
</td>
<td>
<div class="input-group input-group-sm">
<span class="input-group-addon">container</span>
<input type="number" class="form-control" ng-model="portBinding.TargetPort" placeholder="e.g. 80" ng-change="updatePublishedPort(service, mapping)" ng-disabled="isUpdating">
</div>
</td>
<td>
<div class="input-group input-group-sm">
<select class="selectpicker form-control" ng-model="portBinding.Protocol" ng-change="updatePublishedPort(service, mapping)" ng-disabled="isUpdating">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
</td>
<td>
<span class="input-group-btn">
<button class="btn btn-sm btn-danger" type="button" ng-click="removePortPublishedBinding(service, $index)" ng-disabled="isUpdating">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</span>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer>
<div class="btn-toolbar" role="toolbar"
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['Ports'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['Ports'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</div>

View File

@@ -0,0 +1,36 @@
<div>
<rd-widget>
<rd-widget-header icon="fa-list-alt" title="Resource limits and reservations">
</rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>CPU limits</td>
<td ng-if="service.LimitNanoCPUs">
{{ service.LimitNanoCPUs / 1000000000 }}
</td>
<td ng-if="!service.LimitNanoCPUs">None</td>
</tr>
<tr>
<td>Memory limits</td>
<td ng-if="service.LimitMemoryBytes">{{service.LimitMemoryBytes|humansize}}</td>
<td ng-if="!service.LimitMemoryBytes">None</td>
</tr>
<tr>
<td>CPU reservation</td>
<td ng-if="service.ReservationNanoCPUs">
{{service.ReservationNanoCPUs / 1000000000}}
</td>
<td ng-if="!service.ReservationNanoCPUs">None</td>
</tr>
<tr>
<td>Memory reservation</td>
<td ng-if="service.ReservationMemoryBytes">{{service.ReservationMemoryBytes|humansize}}</td>
<td ng-if="!service.ReservationMemoryBytes">None</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>

View File

@@ -0,0 +1,76 @@
<div>
<rd-widget>
<rd-widget-header icon="fa-list-alt" title="Restart policy">
</rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Restart condition</td>
<td>
<div class="input-group input-group-sm">
<select class="selectpicker form-control" ng-model="service.RestartCondition" ng-change="updateServiceAttribute(service, 'RestartCondition')" ng-disabled="isUpdating">
<option value="none">None</option>
<option value="on-failure">On failure</option>
<option value="any">Any</option>
</select>
</div>
</td>
<td>
<p class="small text-muted" style="margin-top: 10px;">
Condition for restart.
</p>
</td>
</tr>
<tr>
<td>Restart delay</td>
<td>
<input class="input-sm" type="number" ng-model="service.RestartDelay" ng-change="updateServiceAttribute(service, 'RestartDelay')" ng-disabled="isUpdating"/>
</td>
<td>
<p class="small text-muted" style="margin-top: 10px;">
Delay between restart attempts. Time in seconds.
</p>
</td>
</tr>
<tr>
<td>Restart max attempts</td>
<td>
<input class="input-sm" type="number" ng-model="service.RestartMaxAttempts" ng-change="updateServiceAttribute(service, 'RestartMaxAttempts')" ng-disabled="isUpdating"/>
</td>
<td>
<p class="small text-muted" style="margin-top: 10px;">
Maximum attempts to restart a given container before giving up (default value is 0, which is ignored).
</p>
</td>
</tr>
<tr>
<td>Restart window</td>
<td>
<input class="input-sm" type="number" ng-model="service.RestartWindow" ng-change="updateServiceAttribute(service, 'RestartWindow')" ng-disabled="isUpdating"/>
</td>
<td>
<p class="small text-muted" style="margin-top: 10px;">
The time window used to evaluate the restart policy (default value is 0, which is unbounded).
</p>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['RestartCondition', 'RestartDelay', 'RestartMaxAttempts', 'RestartWindow'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['RestartCondition', 'RestartDelay', 'RestartMaxAttempts', 'RestartWindow'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</div>

View File

@@ -0,0 +1,63 @@
<div>
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Service labels">
<div class="nopadding">
<a class="btn btn-default btn-sm pull-right" ng-click="isUpdating || addLabel(service)" ng-disabled="isUpdating">
<i class="fa fa-plus-circle" aria-hidden="true"></i> label
</a>
</div>
</rd-widget-header>
<rd-widget-body ng-if="service.ServiceLabels.length === 0">
<p>There are no labels for this service.</p>
</rd-widget-body>
<rd-widget-body classes="no-padding" ng-if="service.ServiceLabels.length > 0">
<table class="table">
<thead>
<tr>
<th>
Label
</th>
<th>
Value
</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="label in service.ServiceLabels">
<td>
<div class="input-group input-group-sm">
<span class="input-group-addon fit-text-size">name</span>
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" ng-change="updateLabel(service, label)" ng-disabled="isUpdating">
</div>
</td>
<td>
<div class="input-group input-group-sm">
<span class="input-group-addon fit-text-size">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" ng-change="updateLabel(service, label)" ng-disabled="isUpdating">
<span class="input-group-btn">
<button class="btn btn-sm btn-danger" type="button" ng-click="removeLabel(service, $index)" ng-disabled="isUpdating">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServiceLabels'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceLabels'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</div>

View File

@@ -0,0 +1,65 @@
<div ng-if="tasks.length > 0">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Associated tasks">
<div class="pull-right">
Items per page:
<select ng-model="state.pagination_count" ng-change="changePaginationCount()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<thead>
<tr>
<th>Id</th>
<th>
<a ui-sref="service" ng-click="order('Status')">
Status
<span ng-show="sortType == 'Status' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Status' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="service" ng-click="order('Slot')">
Slot
<span ng-show="sortType == 'Slot' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Slot' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="displayNode">
<a ui-sref="service" ng-click="order('Node')">
Node
<span ng-show="sortType == 'Node' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Node' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="service" ng-click="order('Updated')">
Last update
<span ng-show="sortType == 'Updated' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Updated' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="task in (filteredTasks = ( tasks | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><a ui-sref="task({ id: task.Id })">{{ task.Id }}</a></td>
<td><span class="label label-{{ task.Status|taskstatusbadge }}">{{ task.Status }}</span></td>
<td>{{ task.Slot }}</td>
<td ng-if="displayNode">{{ task.Node }}</td>
<td>{{ task.Updated|getisodate }}</td>
</tr>
</tbody>
</table>
<div ng-if="tasks" class="pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@@ -0,0 +1,68 @@
<div>
<rd-widget>
<rd-widget-header icon="fa-list-alt" title="Update configuration">
</rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Update Parallelism</td>
<td>
<input class="input-sm" type="number" ng-model="service.UpdateParallelism" ng-change="updateServiceAttribute(service, 'UpdateParallelism')" ng-disabled="isUpdating"/>
</td>
<td>
<p class="small text-muted" style="margin-top: 10px;">
Maximum number of tasks to be updated simultaneously (0 to update all at once).
</p>
</td>
</tr>
<tr>
<td>Update Delay</td>
<td>
<input class="input-sm" type="number" ng-model="service.UpdateDelay" ng-change="updateServiceAttribute(service, 'UpdateDelay')" ng-disabled="isUpdating"/>
</td>
<td>
<p class="small text-muted" style="margin-top: 10px;">
Amount of time between updates.
</p>
</td>
</tr>
<tr>
<td>Update Failure Action</td>
<td>
<div class="form-group">
<label class="radio-inline">
<input type="radio" name="failure_action" ng-model="service.UpdateFailureAction" value="continue" ng-change="updateServiceAttribute(service, 'UpdateFailureAction')" ng-disabled="isUpdating">
Continue
</label>
<label class="radio-inline">
<input type="radio" name="failure_action" ng-model="service.UpdateFailureAction" value="pause" ng-change="updateServiceAttribute(service, 'UpdateFailureAction')" ng-disabled="isUpdating">
Pause
</label>
</div>
</td>
<td>
<p class="small text-muted" style="margin-top: 10px;">
Action taken on failure to start after update.
</p>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['UpdateFailureAction', 'UpdateDelay', 'UpdateParallelism'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['UpdateFailureAction', 'UpdateDelay', 'UpdateParallelism'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</div>

View File

@@ -11,7 +11,16 @@
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<div ng-if="isUpdating" class="col-lg-12 col-md-12 col-xs-12">
<div class="alert alert-info" role="alert" id="service-update-alert">
<p>This service is being updated. Editing this service is currently disabled.</p>
<a ui-sref="service({id: service.Id}, {reload: true})">Refresh to see if this service has finished updated.</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-9 col-md-9 col-xs-9">
<rd-widget>
<rd-widget-header icon="fa-list-alt" title="Service details"></rd-widget-header>
<rd-widget-body classes="no-padding">
@@ -19,23 +28,32 @@
<tbody>
<tr>
<td>Name</td>
<td ng-if="!service.EditName">
{{ service.Name }}
<a href="" data-toggle="tooltip" title="Edit service name" ng-click="service.EditName = true;"><i class="fa fa-edit"></i></a>
<td ng-if="applicationState.endpoint.apiVersion <= 1.24">
<input type="text" class="form-control" ng-model="service.Name" ng-change="updateServiceAttribute(service, 'Name')" ng-disabled="isUpdating">
</td>
<td ng-if="service.EditName">
<input type="text" class="containerNameInput" ng-model="service.newServiceName">
<a class="interactive" ng-click="service.EditName = false;"><i class="fa fa-times"></i></a>
<a class="interactive" ng-click="renameService(service)"><i class="fa fa-check-square-o"></i></a>
<td ng-if="applicationState.endpoint.apiVersion >= 1.25">
{{ service.Name }}
</td>
</tr>
<tr>
<td>ID</td>
<td>
{{ service.Id }}
<button class="btn btn-xs btn-danger" ng-click="removeService()"><i class="fa fa-trash space-right" aria-hidden="true"></i>Delete this service</button>
<button class="btn btn-xs btn-danger" ng-click="removeService()"><i class="fa fa-trash space-right" aria-hidden="true" ng-disabled="isUpdating"></i>Delete this service</button>
</td>
</tr>
<tr ng-if="service.CreatedAt">
<td>Created at</td>
<td>{{ service.CreatedAt|getisodate}}</td>
</tr>
<tr ng-if="service.UpdatedAt">
<td>Last updated at</td>
<td>{{ service.UpdatedAt|getisodate }}</td>
</tr>
<tr ng-if="service.Version">
<td>Version</td>
<td>{{ service.Version }}</td>
</tr>
<tr>
<td>Scheduling mode</td>
<td>{{ service.Mode }}</td>
@@ -43,251 +61,90 @@
<tr ng-if="service.Mode === 'replicated'">
<td>Replicas</td>
<td>
<span ng-if="service.Mode === 'replicated' && !service.EditReplicas">
{{ service.Replicas }}
<a class="interactive" ng-click="service.EditReplicas = true;"><i class="fa fa-arrows-v" aria-hidden="true"></i> Scale</a>
</span>
<span ng-if="service.Mode === 'replicated' && service.EditReplicas">
<input class="input-sm" type="number" ng-model="service.newServiceReplicas" />
<a class="interactive" ng-click="service.EditReplicas = false;"><i class="fa fa-times"></i></a>
<a class="interactive" ng-click="scaleService(service)"><i class="fa fa-check-square-o"></i></a>
<span ng-if="service.Mode === 'replicated'">
<input class="input-sm" type="number" ng-model="service.Replicas" ng-change="updateServiceAttribute(service, 'Replicas')" ng-disabled="isUpdating" />
</span>
</td>
</tr>
<tr>
<td>Image</td>
<td ng-if="!service.EditImage">
{{ service.Image }}
<a href="" data-toggle="tooltip" title="Edit service image" ng-click="service.EditImage = true;"><i class="fa fa-edit"></i></a>
</td>
<td ng-if="service.EditImage">
<input type="text" class="containerNameInput" ng-model="service.newServiceImage">
<a class="interactive" ng-click="service.EditImage = false;"><i class="fa fa-times"></i></a>
<a class="interactive" ng-click="changeServiceImage(service)"><i class="fa fa-check-square-o"></i></a>
</td>
</tr>
<tr ng-if="service.Ports">
<td>Published ports</td>
<td>
<div ng-repeat="mapping in service.Ports">
{{ mapping.TargetPort }} <i class="fa fa-long-arrow-right"></i> {{ mapping.PublishedPort }}
</div>
</td>
</tr>
<tr ng-if="service.EnvironmentVariables">
<td>Environment variables</td>
<td>
<div class="form-group">
<div class="col-sm-11 nopadding">
<span class="label label-default interactive fit-text-size" ng-click="addEnvironmentVariable(service)">
<i class="fa fa-plus-circle" aria-hidden="true"></i> environment variable
</span>
</div>
<!-- environment-variable-input-list -->
<div class="col-sm-11 form-inline nopadding" style="margin-top: 10px;">
<div ng-repeat="var in service.EnvironmentVariables" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon fit-text-size">name</span>
<input type="text" class="form-control" ng-model="var.key" ng-disabled="var.added" placeholder="e.g. FOO">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon fit-text-size">value</span>
<input type="text" class="form-control" ng-model="var.value" ng-change="updateEnvironmentVariable(service, var)" placeholder="e.g. bar">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeEnvironmentVariable(service, $index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
</div>
</div>
</div>
<!-- !environment-variable-input-list -->
</div>
</td>
</tr>
<tr>
<td>Labels</td>
<td>
<div class="form-group">
<div class="col-sm-11 nopadding">
<span class="label label-default interactive fit-text-size" ng-click="addLabel(service)">
<i class="fa fa-plus-circle" aria-hidden="true"></i> label
</span>
</div>
<!-- labels-input-list -->
<div class="col-sm-11 form-inline nopadding" style="margin-top: 10px;">
<div ng-repeat="label in service.ServiceLabels" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon fit-text-size">name</span>
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" ng-change="updateLabel(service, label)">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon fit-text-size">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" ng-change="updateLabel(service, label)">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeLabel(service, $index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
</div>
</div>
</div>
<!-- !labels-input-list -->
</div>
</td>
</tr>
<tr>
<td>Container labels</td>
<td>
<div class="form-group">
<div class="col-sm-11 nopadding">
<span class="label label-default interactive fit-text-size" ng-click="addContainerLabel(service)">
<i class="fa fa-plus-circle" aria-hidden="true"></i> container label
</span>
</div>
<!-- labels-input-list -->
<div class="col-sm-11 form-inline nopadding" style="margin-top: 10px;">
<div ng-repeat="label in service.ServiceContainerLabels" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon fit-text-size">name</span>
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" ng-change="updateLabel(service, label)">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon fit-text-size">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" ng-change="updateLabel(service, label)">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeContainerLabel(service, $index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
</div>
</div>
</div>
<!-- !labels-input-list -->
</div>
</td>
</tr>
<tr>
<td>Update Parallelism</td>
<td>
<span ng-if="!service.EditParallelism">
{{ service.UpdateParallelism }}
<a class="interactive" ng-click="service.EditParallelism = true;"><i class="fa fa-arrows-v" aria-hidden="true"></i> Change</a>
</span>
<span ng-if="service.EditParallelism">
<input class="input-sm" type="number" ng-model="service.newServiceUpdateParallelism" />
<a class="interactive" ng-click="service.EditParallelism = false;"><i class="fa fa-times"></i></a>
<a class="interactive" ng-click="changeParallelism(service)"><i class="fa fa-check-square-o"></i></a>
</span>
</td>
</tr>
<tr>
<td>Update Delay</td>
<td>
<span ng-if="!service.EditDelay">
{{ service.UpdateDelay }}
<a class="interactive" ng-click="service.EditDelay = true;"><i class="fa fa-arrows-v" aria-hidden="true"></i> Change</a>
</span>
<span ng-if="service.EditDelay">
<input class="input-sm" type="number" ng-model="service.newServiceUpdateDelay" />
<a class="interactive" ng-click="service.EditDelay = false;"><i class="fa fa-times"></i></a>
<a class="interactive" ng-click="changeUpdateDelay(service)"><i class="fa fa-check-square-o"></i></a>
</span>
</td>
</tr>
<tr>
<td>Update Failure Action</td>
<td>
<div class="form-group">
<label class="radio-inline">
<input type="radio" name="failure_action" ng-model="service.newServiceUpdateFailureAction" value="continue" ng-change="changeUpdateFailureAction(service)">
Continue
</label>
<label class="radio-inline">
<input type="radio" name="failure_action" ng-model="service.newServiceUpdateFailureAction" value="pause" ng-change="changeUpdateFailureAction(service)">
Pause
</label>
</div>
<input type="text" class="form-control" ng-model="service.Image" ng-change="updateServiceAttribute(service, 'Image')" ng-disabled="isUpdating" />
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer ng-if="service.hasChanges">
<div>
<button type="button" class="btn btn-primary" ng-click="updateService(service)">Save changes</button>
<button type="button" class="btn btn-default" ng-click="cancelChanges(service)">Reset</button>
<rd-widget-footer>
<p class="small text-muted">
Do you need help? View the Docker Service documentation <a href="https://docs.docker.com/engine/reference/commandline/service_update/" target="self">here</a>.
</p>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary" ng-disabled="!hasChanges(service, ['Mode', 'Replicas', 'Image', 'Name'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['Mode', 'Replicas', 'Image', 'Name'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</div>
</div>
<div class="row" ng-if="tasks.length > 0">
<div class="col-lg-12 col-md-12 col-xs-12">
<div class="col-lg-3 col-md-3 col-xs-3">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Associated tasks">
<div class="pull-right">
Items per page:
<select ng-model="state.pagination_count" ng-change="changePaginationCount()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-header icon="fa-bars" title="Quick navigation"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<thead>
<tr>
<th>Id</th>
<th>
<a ui-sref="service" ng-click="order('Status')">
Status
<span ng-show="sortType == 'Status' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Status' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="service" ng-click="order('Slot')">
Slot
<span ng-show="sortType == 'Slot' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Slot' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="displayNode">
<a ui-sref="service" ng-click="order('Node')">
Node
<span ng-show="sortType == 'Node' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Node' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="service" ng-click="order('Updated')">
Last update
<span ng-show="sortType == 'Updated' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Updated' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="task in (filteredTasks = ( tasks | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><a ui-sref="task({ id: task.Id })">{{ task.Id }}</a></td>
<td><span class="label label-{{ task.Status|taskstatusbadge }}">{{ task.Status }}</span></td>
<td>{{ task.Slot }}</td>
<td ng-if="displayNode">{{ task.Node }}</td>
<td>{{ task.Updated|getisodate }}</td>
</tr>
</tbody>
</table>
<div ng-if="tasks" class="pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
<ul class="nav nav-pills nav-stacked">
<li><a href ng-click="goToItem('service-env-variables')">Environment variables</a></li>
<li><a href ng-click="goToItem('service-container-labels')">Container labels</a></li>
<li><a href ng-click="goToItem('service-mounts')">Mounts</a></li>
<li><a href ng-click="goToItem('service-network-specs')">Network &amp; published ports</a></li>
<li><a href ng-click="goToItem('service-resources')">Resource limits &amp; reservations</a></li>
<li><a href ng-click="goToItem('service-placement-constraints')">Placement constraints</a></li>
<li><a href ng-click="goToItem('service-restart-policy')">Restart policy</a></li>
<li><a href ng-click="goToItem('service-update-config')">Update configuration</a></li>
<li><a href ng-click="goToItem('service-labels')">Service labels</a></li>
<li><a href ng-click="goToItem('service-tasks')">Tasks</a></li>
<ul>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<hr>
<div class="col-lg-12 col-md-12 col-xs-12">
<h3 id="container-specs">Container specification</h3>
<div id="service-container-spec" class="padding-top" ng-include="'app/components/service/includes/container-specs.html'"></div>
<div id="service-env-variables" class="padding-top" ng-include="'app/components/service/includes/environmentvariables.html'"></div>
<div id="service-container-labels" class="padding-top" ng-include="'app/components/service/includes/containerlabels.html'"></div>
<div id="service-mounts" class="padding-top" ng-include="'app/components/service/includes/mounts.html'"></div>
</div>
</div>
<div class="row">
<hr>
<div class="col-lg-12 col-md-12 col-xs-12">
<h3 id="service-network-specs">Networks &amp; ports</h3>
<div id="service-networks" class="padding-top" ng-include="'app/components/service/includes/networks.html'"></div>
<div id="service-published-ports" class="padding-top" ng-include="'app/components/service/includes/ports.html'"></div>
</div>
</div>
<div class="row">
<hr>
<div class="col-lg-12 col-md-12 col-xs-12">
<h3 id="service-specs">Service specification</h3>
<div id="service-resources" class="padding-top" ng-include="'app/components/service/includes/resources.html'"></div>
<div id="service-placement-constraints" class="padding-top" ng-include="'app/components/service/includes/constraints.html'"></div>
<div id="service-restart-policy" class="padding-top" ng-include="'app/components/service/includes/restart.html'"></div>
<div id="service-update-config" class="padding-top" ng-include="'app/components/service/includes/updateconfig.html'"></div>
<div id="service-labels" class="padding-top" ng-include="'app/components/service/includes/servicelabels.html'"></div>
<div id="service-tasks" class="padding-top" ng-include="'app/components/service/includes/tasks.html'"></div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
angular.module('service', [])
.controller('ServiceController', ['$scope', '$stateParams', '$state', 'Service', 'ServiceHelper', 'Task', 'Node', 'Messages', 'Pagination',
function ($scope, $stateParams, $state, Service, ServiceHelper, Task, Node, Messages, Pagination) {
.controller('ServiceController', ['$scope', '$stateParams', '$state', '$location', '$anchorScroll', 'Service', 'ServiceHelper', 'Task', 'Node', 'Messages', 'Pagination',
function ($scope, $stateParams, $state, $location, $anchorScroll, Service, ServiceHelper, Task, Node, Messages, Pagination) {
$scope.state = {};
$scope.state.pagination_count = Pagination.getPaginationCount('service_tasks');
@@ -10,7 +10,10 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Task, Node, Mess
$scope.sortType = 'Status';
$scope.sortReverse = false;
var previousServiceValues = {};
$scope.lastVersion = 0;
var originalService = {};
var previousServiceValues = [];
$scope.order = function (sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
@@ -35,85 +38,175 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Task, Node, Mess
service.EditReplicas = false;
};
$scope.goToItem = function(hash) {
$anchorScroll(hash);
};
$scope.addEnvironmentVariable = function addEnvironmentVariable(service) {
service.EnvironmentVariables.push({ key: '', value: '', originalValue: '' });
service.hasChanges = true;
updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables);
};
$scope.removeEnvironmentVariable = function removeEnvironmentVariable(service, index) {
var removedElement = service.EnvironmentVariables.splice(index, 1);
service.hasChanges = service.hasChanges || removedElement !== null;
if (removedElement !== null) {
updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables);
}
};
$scope.updateEnvironmentVariable = function updateEnvironmentVariable(service, variable) {
service.hasChanges = service.hasChanges || variable.value !== variable.originalValue;
if (variable.value !== variable.originalValue || variable.key !== variable.originalKey) {
updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables);
}
};
$scope.addLabel = function addLabel(service) {
service.hasChanges = true;
service.ServiceLabels.push({ key: '', value: '', originalValue: '' });
updateServiceArray(service, 'ServiceLabels', service.ServiceLabels);
};
$scope.removeLabel = function removeLabel(service, index) {
var removedElement = service.ServiceLabels.splice(index, 1);
service.hasChanges = service.hasChanges || removedElement !== null;
if (removedElement !== null) {
updateServiceArray(service, 'ServiceLabels', service.ServiceLabels);
}
};
$scope.updateLabel = function updateLabel(service, label) {
service.hasChanges = service.hasChanges || label.value !== label.originalValue;
if (label.value !== label.originalValue || label.key !== label.originalKey) {
updateServiceArray(service, 'ServiceLabels', service.ServiceLabels);
}
};
$scope.addContainerLabel = function addContainerLabel(service) {
service.hasChanges = true;
service.ServiceContainerLabels.push({ key: '', value: '', originalValue: '' });
updateServiceArray(service, 'ServiceContainerLabels', service.ServiceContainerLabels);
};
$scope.removeContainerLabel = function removeContainerLabel(service, index) {
$scope.removeContainerLabel = function removeLabel(service, index) {
var removedElement = service.ServiceContainerLabels.splice(index, 1);
service.hasChanges = service.hasChanges || removedElement !== null;
if (removedElement !== null) {
updateServiceArray(service, 'ServiceContainerLabels', service.ServiceContainerLabels);
}
};
$scope.updateContainerLabel = function updateLabel(service, label) {
if (label.value !== label.originalValue || label.key !== label.originalKey) {
updateServiceArray(service, 'ServiceContainerLabels', service.ServiceContainerLabels);
}
};
$scope.addMount = function addMount(service) {
service.ServiceMounts.push({Type: 'volume', Source: '', Target: '', ReadOnly: false });
updateServiceArray(service, 'ServiceMounts', service.ServiceMounts);
};
$scope.removeMount = function removeMount(service, index) {
var removedElement = service.ServiceMounts.splice(index, 1);
if (removedElement !== null) {
updateServiceArray(service, 'ServiceMounts', service.ServiceMounts);
}
};
$scope.updateMount = function updateMount(service, mount) {
updateServiceArray(service, 'ServiceMounts', service.ServiceMounts);
};
$scope.addPlacementConstraint = function addPlacementConstraint(service) {
service.ServiceConstraints.push({ key: '', operator: '==', value: '' });
updateServiceArray(service, 'ServiceConstraints', service.ServiceConstraints);
};
$scope.removePlacementConstraint = function removePlacementConstraint(service, index) {
var removedElement = service.ServiceConstraints.splice(index, 1);
if (removedElement !== null) {
updateServiceArray(service, 'ServiceConstraints', service.ServiceConstraints);
}
};
$scope.updatePlacementConstraint = function updatePlacementConstraint(service, constraint) {
updateServiceArray(service, 'ServiceConstraints', service.ServiceConstraints);
};
$scope.changeParallelism = function changeParallelism(service) {
updateServiceAttribute(service, 'UpdateParallelism', service.newServiceUpdateParallelism);
service.EditParallelism = false;
$scope.addPublishedPort = function addPublishedPort(service) {
if (!service.Ports) {
service.Ports = [];
}
service.Ports.push({ PublishedPort: '', TargetPort: '', Protocol: 'tcp' });
};
$scope.changeUpdateDelay = function changeUpdateDelay(service) {
updateServiceAttribute(service, 'UpdateDelay', service.newServiceUpdateDelay);
service.EditDelay = false;
$scope.updatePublishedPort = function updatePublishedPort(service, portMapping) {
updateServiceArray(service, 'Ports', service.Ports);
};
$scope.changeUpdateFailureAction = function changeUpdateFailureAction(service) {
updateServiceAttribute(service, 'UpdateFailureAction', service.newServiceUpdateFailureAction);
$scope.removePortPublishedBinding = function removePortPublishedBinding(service, index) {
var removedElement = service.Ports.splice(index, 1);
if (removedElement !== null) {
updateServiceArray(service, 'Ports', service.Ports);
}
};
$scope.cancelChanges = function changeServiceImage(service) {
Object.keys(previousServiceValues).forEach(function(attribute) {
service[attribute] = previousServiceValues[attribute]; // reset service values
service['newService' + attribute] = previousServiceValues[attribute]; // reset edit fields
$scope.cancelChanges = function cancelChanges(service, keys) {
if (keys) { // clean out the keys only from the list of modified keys
keys.forEach(function(key) {
var index = previousServiceValues.indexOf(key);
if (index >= 0) {
previousServiceValues.splice(index, 1);
}
});
} else { // clean out all changes
keys = Object.keys(service);
previousServiceValues = [];
}
keys.forEach(function(attribute) {
service[attribute] = originalService[attribute]; // reset service values
});
previousServiceValues = {}; // clear out all changes
// clear out environment variable changes
service.EnvironmentVariables = translateEnvironmentVariables(service.Env);
service.ServiceLabels = translateLabelsToServiceLabels(service.Labels);
service.ServiceContainerLabels = translateLabelsToServiceLabels(service.ContainerLabels);
service.hasChanges = false;
};
$scope.hasChanges = function(service, elements) {
var hasChanges = false;
elements.forEach(function(key) {
hasChanges = hasChanges || (previousServiceValues.indexOf(key) >= 0);
});
return hasChanges;
};
$scope.updateService = function updateService(service) {
$('#loadServicesSpinner').show();
var config = ServiceHelper.serviceToConfig(service.Model);
config.Name = service.newServiceName;
config.Name = service.Name;
config.Labels = translateServiceLabelsToLabels(service.ServiceLabels);
config.TaskTemplate.ContainerSpec.Env = translateEnvironmentVariablesToEnv(service.EnvironmentVariables);
config.TaskTemplate.ContainerSpec.Labels = translateServiceLabelsToLabels(service.ServiceContainerLabels);
config.TaskTemplate.ContainerSpec.Image = service.newServiceImage;
config.TaskTemplate.ContainerSpec.Image = service.Image;
config.TaskTemplate.ContainerSpec.Secrets = service.ServiceSecrets;
if (service.Mode === 'replicated') {
config.Mode.Replicated.Replicas = service.Replicas;
}
config.TaskTemplate.ContainerSpec.Mounts = service.ServiceMounts;
if (typeof config.TaskTemplate.Placement === 'undefined') {
config.TaskTemplate.Placement = {};
}
config.TaskTemplate.Placement.Constraints = translateKeyValueToConstraints(service.ServiceConstraints);
config.TaskTemplate.Resources = {
Limits: {
NanoCPUs: service.LimitNanoCPUs,
MemoryBytes: service.LimitMemoryBytes
},
Reservations: {
NanoCPUs: service.ReservationNanoCPUs,
MemoryBytes: service.ReservationMemoryBytes
}
};
config.UpdateConfig = {
Parallelism: service.newServiceUpdateParallelism,
Delay: service.newServiceUpdateDelay,
FailureAction: service.newServiceUpdateFailureAction
Parallelism: service.UpdateParallelism,
Delay: service.UpdateDelay,
FailureAction: service.UpdateFailureAction
};
config.TaskTemplate.RestartPolicy = {
Condition: service.RestartCondition,
Delay: service.RestartDelay,
MaxAttempts: service.RestartMaxAttempts,
Window: service.RestartWindow
};
config.EndpointSpec = {
Mode: config.EndpointSpec.Mode || 'vip',
Ports: service.Ports
};
Service.update({ id: service.Id, version: service.Version }, config, function (data) {
$('#loadServicesSpinner').hide();
Messages.send("Service successfully updated", "Service updated");
$state.go('service', {id: service.Id}, {reload: true});
$scope.cancelChanges({});
fetchServiceDetails();
}, function (e) {
$('#loadServicesSpinner').hide();
Messages.error("Failure", e, "Unable to update service");
@@ -138,22 +231,28 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Task, Node, Mess
});
};
function translateServiceArrays(service) {
service.ServiceSecrets = service.Secrets;
service.EnvironmentVariables = translateEnvironmentVariables(service.Env);
service.ServiceLabels = translateLabelsToServiceLabels(service.Labels);
service.ServiceContainerLabels = translateLabelsToServiceLabels(service.ContainerLabels);
service.ServiceMounts = angular.copy(service.Mounts);
service.ServiceConstraints = translateConstraintsToKeyValue(service.Constraints);
}
function fetchServiceDetails() {
$('#loadingViewSpinner').show();
Service.get({id: $stateParams.id}, function (d) {
var service = new ServiceViewModel(d);
service.newServiceName = service.Name;
service.newServiceImage = service.Image;
service.newServiceReplicas = service.Replicas;
service.newServiceUpdateParallelism = service.UpdateParallelism;
service.newServiceUpdateDelay = service.UpdateDelay;
service.newServiceUpdateFailureAction = service.UpdateFailureAction;
service.EnvironmentVariables = translateEnvironmentVariables(service.Env);
service.ServiceLabels = translateLabelsToServiceLabels(service.Labels);
service.ServiceContainerLabels = translateLabelsToServiceLabels(service.ContainerLabels);
$scope.isUpdating = $scope.lastVersion >= service.Version;
if (!$scope.isUpdating) {
$scope.lastVersion = service.Version;
}
translateServiceArrays(service);
$scope.service = service;
originalService = angular.copy(service);
Task.query({filters: {service: [service.Name]}}, function (tasks) {
Node.query({}, function (nodes) {
$scope.displayNode = true;
@@ -178,13 +277,15 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Task, Node, Mess
});
}
function updateServiceAttribute(service, name, newValue) {
// ensure we only capture the original previous value, in case we update the attribute multiple times
if (!previousServiceValues[name]) {
previousServiceValues[name] = service[name];
$scope.updateServiceAttribute = function updateServiceAttribute(service, name) {
if (service[name] !== originalService[name] || !(name in originalService)) {
service.hasChanges = true;
}
// update the attribute
service[name] = newValue;
previousServiceValues.push(name);
};
function updateServiceArray(service, name) {
previousServiceValues.push(name);
service.hasChanges = true;
}
@@ -195,7 +296,7 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Task, Node, Mess
var idx = variable.indexOf('=');
var keyValue = [variable.slice(0,idx), variable.slice(idx+1)];
var originalValue = (keyValue.length > 1) ? keyValue[1] : '';
variables.push({ key: keyValue[0], value: originalValue, originalValue: originalValue, added: true});
variables.push({ key: keyValue[0], value: originalValue, originalKey: keyValue[0], originalValue: originalValue, added: true});
});
return variables;
}
@@ -218,7 +319,7 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Task, Node, Mess
var labels = [];
if (Labels) {
Object.keys(Labels).forEach(function(key) {
labels.push({ key: key, value: Labels[key], originalValue: Labels[key], added: true});
labels.push({ key: key, value: Labels[key], originalKey: key, originalValue: Labels[key], added: true});
});
}
return labels;
@@ -233,5 +334,48 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Task, Node, Mess
return Labels;
}
function translateConstraintsToKeyValue(constraints) {
function getOperator(constraint) {
var indexEquals = constraint.indexOf('==');
if (indexEquals >= 0) {
return [indexEquals, '=='];
}
return [constraint.indexOf('!='), '!='];
}
if (constraints) {
var keyValueConstraints = [];
constraints.forEach(function(constraint) {
var operatorIndices = getOperator(constraint);
var key = constraint.slice(0, operatorIndices[0]);
var operator = operatorIndices[1];
var value = constraint.slice(operatorIndices[0] + 2);
keyValueConstraints.push({
key: key,
value: value,
operator: operator,
originalKey: key,
originalValue: value
});
});
return keyValueConstraints;
}
return [];
}
function translateKeyValueToConstraints(keyValueConstraints) {
if (keyValueConstraints) {
var constraints = [];
keyValueConstraints.forEach(function(keyValueConstraint) {
if (keyValueConstraint.key && keyValueConstraint.key !== '' && keyValueConstraint.value && keyValueConstraint.value !== '') {
constraints.push(keyValueConstraint.key + keyValueConstraint.operator + keyValueConstraint.value);
}
});
return constraints;
}
return [];
}
fetchServiceDetails();
}]);

View File

@@ -26,7 +26,7 @@
<rd-widget-taskbar classes="col-lg-12 col-md-12 col-xs-12">
<div class="pull-left">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<a class="btn btn-default btn-responsive" type="button" ui-sref="actions.create.service">Add service</a>
<a class="btn btn-primary" type="button" ui-sref="actions.create.service"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add service</a>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
@@ -58,16 +58,39 @@
<span ng-show="sortType == 'Mode' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="services" ng-click="order('Ports')">
Published Ports
<span ng-show="sortType == 'Ports' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Ports' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="services" ng-click="order('UpdatedAt')">
Updated at
<span ng-show="sortType == 'UpdatedAt' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'UpdatedAt' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">
<a ui-sref="services" ng-click="order('Metadata.ResourceControl.OwnerId')">
Ownership
<span ng-show="sortType == 'Metadata.ResourceControl.OwnerId' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Metadata.ResourceControl.OwnerId' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</thead>
<tbody>
<tr dir-paginate="service in (state.filteredServices = ( services | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="service.Checked" ng-change="selectItem(service)"/></td>
<td><a ui-sref="service({id: service.Id})">{{ service.Name }}</a></td>
<td>{{ service.Image }}</td>
<td>{{ service.Image | hideshasum }}</td>
<td>
{{ service.Mode }}
<code data-toggle="tooltip" title="Replicas">{{ service.Running }}</code>
/
<code data-toggle="tooltip" title="Replicas">{{ service.Replicas }}</code>
<span ng-if="service.Mode === 'replicated' && !service.Scale">
<code data-toggle="tooltip" title="Replicas">{{ service.Replicas }}</code>
<a class="interactive" ng-click="service.Scale = true; service.ReplicaCount = service.Replicas;"><i class="fa fa-arrows-v" aria-hidden="true"></i> Scale</a>
</span>
<span ng-if="service.Mode === 'replicated' && service.Scale">
@@ -76,12 +99,42 @@
<a class="interactive" ng-click="scaleService(service)"><i class="fa fa-check-square-o"></i></a>
</span>
</td>
<td>
<a ng-if="service.Ports && service.Ports.length > 0 && swarmManagerIP" ng-repeat="p in service.Ports" class="image-tag" ng-href="http://{{swarmManagerIP}}:{{p.PublishedPort}}" target="_blank">
<i class="fa fa-external-link" aria-hidden="true"></i> {{ p.PublishedPort }}:{{ p.TargetPort }}
</a>
<span ng-if="!service.Ports || service.Ports.length === 0 || !swarmManagerIP" >-</span>
</td>
<td>
{{ service.UpdatedAt|getisodate }}
</td>
<td ng-if="applicationState.application.authentication">
<span ng-if="user.role === 1 && service.Metadata.ResourceControl">
<i class="fa fa-eye-slash" aria-hidden="true"></i>
<span ng-if="service.Metadata.ResourceControl.OwnerId === user.ID">
Private
</span>
<span ng-if="service.Metadata.ResourceControl.OwnerId !== user.ID">
Private <span ng-if="service.Owner">(owner: {{ service.Owner }})</span>
</span>
<a ng-click="switchOwnership(service)" class="interactive"><i class="fa fa-eye" aria-hidden="true" style="margin-left: 7px;"></i> Switch to public</a>
</span>
<span ng-if="user.role !== 1 && service.Metadata.ResourceControl.OwnerId === user.ID">
<i class="fa fa-eye-slash" aria-hidden="true"></i>
Private
<a ng-click="switchOwnership(service)" class="interactive"><i class="fa fa-eye" aria-hidden="true" style="margin-left: 7px;"></i> Switch to public</a>
</span>
<span ng-if="!service.Metadata.ResourceControl">
<i class="fa fa-eye" aria-hidden="true"></i>
Public
</span>
</td>
</tr>
<tr ng-if="!services">
<td colspan="4" class="text-center text-muted">Loading...</td>
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="services.length == 0">
<td colspan="4" class="text-center text-muted">No services available.</td>
<td colspan="5" class="text-center text-muted">No services available.</td>
</tr>
</tbody>
</table>

View File

@@ -1,12 +1,40 @@
angular.module('services', [])
.controller('ServicesController', ['$scope', '$stateParams', '$state', 'Service', 'ServiceHelper', 'Messages', 'Pagination',
function ($scope, $stateParams, $state, Service, ServiceHelper, Messages, Pagination) {
.controller('ServicesController', ['$q', '$scope', '$stateParams', '$state', 'Service', 'ServiceHelper', 'Messages', 'Pagination', 'Task', 'Node', 'NodeHelper', 'Authentication', 'UserService', 'ModalService', 'ResourceControlService',
function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Messages, Pagination, Task, Node, NodeHelper, Authentication, UserService, ModalService, ResourceControlService) {
$scope.state = {};
$scope.state.selectedItemCount = 0;
$scope.state.pagination_count = Pagination.getPaginationCount('services');
$scope.sortType = 'Name';
$scope.sortReverse = false;
function removeServiceResourceControl(service) {
volumeResourceControlQueries = [];
angular.forEach(service.Mounts, function (mount) {
if (mount.Type === 'volume') {
volumeResourceControlQueries.push(ResourceControlService.removeVolumeResourceControl(service.Metadata.ResourceControl.OwnerId, mount.Source));
}
});
$q.all(volumeResourceControlQueries)
.then(function success() {
return ResourceControlService.removeServiceResourceControl(service.Metadata.ResourceControl.OwnerId, service.Id);
})
.then(function success() {
delete service.Metadata.ResourceControl;
Messages.send('Ownership changed to public', service.Id);
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to change service ownership");
});
}
$scope.switchOwnership = function(volume) {
ModalService.confirmServiceOwnershipChange(function (confirmed) {
if(!confirmed) { return; }
removeServiceResourceControl(volume);
});
};
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('services', $scope.state.pagination_count);
};
@@ -57,9 +85,21 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Messages, Pagina
$('#loadServicesSpinner').hide();
Messages.error("Unable to remove service", {}, d[0].message);
} else {
Messages.send("Service deleted", service.Id);
var index = $scope.services.indexOf(service);
$scope.services.splice(index, 1);
if (service.Metadata && service.Metadata.ResourceControl) {
ResourceControlService.removeServiceResourceControl(service.Metadata.ResourceControl.OwnerId, service.Id)
.then(function success() {
Messages.send("Service deleted", service.Id);
var index = $scope.services.indexOf(service);
$scope.services.splice(index, 1);
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to remove service ownership");
});
} else {
Messages.send("Service deleted", service.Id);
var index = $scope.services.indexOf(service);
$scope.services.splice(index, 1);
}
}
complete();
}, function (e) {
@@ -70,17 +110,59 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Messages, Pagina
});
};
function mapUsersToServices(users) {
angular.forEach($scope.services, function (service) {
if (service.Metadata) {
var serviceRC = service.Metadata.ResourceControl;
if (serviceRC && serviceRC.OwnerId !== $scope.user.ID) {
angular.forEach(users, function (user) {
if (serviceRC.OwnerId === user.Id) {
service.Owner = user.Username;
}
});
}
}
});
}
function fetchServices() {
$('#loadServicesSpinner').show();
Service.query({}, function (d) {
$scope.services = d.map(function (service) {
return new ServiceViewModel(service);
var userDetails = Authentication.getUserDetails();
$scope.user = userDetails;
$q.all({
services: Service.query({}).$promise,
tasks: Task.query({filters: {'desired-state': ['running']}}).$promise,
nodes: Node.query({}).$promise,
})
.then(function success(data) {
$scope.swarmManagerIP = NodeHelper.getManagerIP(data.nodes);
$scope.services = data.services.map(function (service) {
var serviceTasks = data.tasks.filter(function (task) {
return task.ServiceID === service.ID;
});
var taskNodes = data.nodes.filter(function (node) {
return node.Spec.Availability === 'active' && node.Status.State === 'ready';
});
return new ServiceViewModel(service, serviceTasks, taskNodes);
});
$('#loadServicesSpinner').hide();
}, function(e) {
$('#loadServicesSpinner').hide();
Messages.error("Failure", e, "Unable to retrieve services");
if (userDetails.role === 1) {
UserService.users()
.then(function success(data) {
mapUsersToServices(data);
})
.finally(function final() {
$('#loadServicesSpinner').hide();
});
}
})
.catch(function error(err) {
$scope.services = [];
Messages.error("Failure", err, "Unable to retrieve services");
})
.finally(function final() {
$('#loadServicesSpinner').hide();
});
}

View File

@@ -1,6 +1,6 @@
angular.module('settings', [])
.controller('SettingsController', ['$scope', '$state', '$sanitize', 'Users', 'Messages',
function ($scope, $state, $sanitize, Users, Messages) {
.controller('SettingsController', ['$scope', '$state', '$sanitize', 'Authentication', 'UserService', 'Messages',
function ($scope, $state, $sanitize, Authentication, UserService, Messages) {
$scope.formValues = {
currentPassword: '',
newPassword: '',
@@ -9,22 +9,21 @@ function ($scope, $state, $sanitize, Users, Messages) {
$scope.updatePassword = function() {
$scope.invalidPassword = false;
$scope.error = false;
var userID = Authentication.getUserDetails().ID;
var currentPassword = $sanitize($scope.formValues.currentPassword);
Users.checkPassword({ username: $scope.username, password: currentPassword }, function (d) {
if (d.valid) {
var newPassword = $sanitize($scope.formValues.newPassword);
Users.update({ username: $scope.username, password: newPassword }, function (d) {
Messages.send("Success", "Password successfully updated");
$state.reload();
}, function (e) {
Messages.error("Failure", e, "Unable to update password");
});
} else {
var newPassword = $sanitize($scope.formValues.newPassword);
UserService.updateUserPassword(userID, currentPassword, newPassword)
.then(function success() {
Messages.send("Success", "Password successfully updated");
$state.reload();
})
.catch(function error(err) {
if (err.invalidPassword) {
$scope.invalidPassword = true;
} else {
Messages.error("Failure", err, err.msg);
}
}, function (e) {
Messages.error("Failure", e, "Unable to check password validity");
});
};
}]);

View File

@@ -47,10 +47,13 @@
<a ui-sref="docker" ui-sref-active="active">Docker <span class="menu-icon fa fa-th"></span></a>
</li>
<li class="sidebar-title"><span>Portainer settings</span></li>
<li class="sidebar-list">
<li class="sidebar-list" ng-if="applicationState.application.authentication">
<a ui-sref="settings" ui-sref-active="active">Password <span class="menu-icon fa fa-lock"></span></a>
</li>
<li class="sidebar-list">
<li class="sidebar-list" ng-if="applicationState.application.authentication && userRole === 1">
<a ui-sref="users" ui-sref-active="active">Users <span class="menu-icon fa fa-user"></span></a>
</li>
<li class="sidebar-list" ng-if="!applicationState.application.authentication || userRole === 1">
<a ui-sref="endpoints" ui-sref-active="active">Endpoints <span class="menu-icon fa fa-plug"></span></a>
</li>
</ul>

View File

@@ -1,39 +1,41 @@
angular.module('sidebar', [])
.controller('SidebarController', ['$scope', '$state', 'Settings', 'Config', 'EndpointService', 'StateManager', 'Messages',
function ($scope, $state, Settings, Config, EndpointService, StateManager, Messages) {
.controller('SidebarController', ['$scope', '$state', 'Settings', 'Config', 'EndpointService', 'StateManager', 'EndpointProvider', 'Messages', 'Authentication',
function ($scope, $state, Settings, Config, EndpointService, StateManager, EndpointProvider, Messages, Authentication) {
Config.$promise.then(function (c) {
$scope.logo = c.logo;
});
$scope.uiVersion = Settings.uiVersion;
$scope.userRole = Authentication.getUserDetails().role;
$scope.switchEndpoint = function(endpoint) {
EndpointService.setActive(endpoint.Id).then(function success(data) {
var activeEndpointID = EndpointProvider.endpointID();
EndpointProvider.setEndpointID(endpoint.Id);
StateManager.updateEndpointState(true)
.then(function success() {
$state.go('dashboard');
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to connect to the Docker endpoint");
EndpointProvider.setEndpointID(activeEndpointID);
StateManager.updateEndpointState(true)
.then(function success() {
$state.reload();
}, function error(err) {
Messages.error("Failure", err, "Unable to connect to the Docker endpoint");
});
}, function error(err) {
Messages.error("Failure", err, "Unable to switch to new endpoint");
.then(function success() {});
});
};
function fetchEndpoints() {
EndpointService.endpoints().then(function success(data) {
EndpointService.endpoints()
.then(function success(data) {
$scope.endpoints = data;
EndpointService.getActive().then(function success(data) {
angular.forEach($scope.endpoints, function (endpoint) {
if (endpoint.Id === data.Id) {
$scope.activeEndpoint = endpoint;
}
});
}, function error(err) {
Messages.error("Failure", err, "Unable to retrieve active endpoint");
var activeEndpointID = EndpointProvider.endpointID();
angular.forEach($scope.endpoints, function (endpoint) {
if (endpoint.Id === activeEndpointID) {
$scope.activeEndpoint = endpoint;
}
});
}, function error(err) {
})
.catch(function error(err) {
$scope.endpoints = [];
});
}

View File

@@ -22,6 +22,8 @@ function (Pagination, $scope, Messages, $timeout, Container, ContainerTop, $stat
$scope.containerTop = data;
});
};
var destroyed = false;
var timeout;
$document.ready(function(){
var cpuLabels = [];
var cpuData = [];
@@ -117,11 +119,6 @@ function (Pagination, $scope, Messages, $timeout, Container, ContainerTop, $stat
});
$scope.networkLegend = $sce.trustAsHtml(networkChart.generateLegend());
function setUpdateStatsTimeout() {
if(!destroyed) {
timeout = $timeout(updateStats, 5000);
}
}
function updateStats() {
Container.stats({id: $stateParams.id}, function (d) {
@@ -145,8 +142,7 @@ function (Pagination, $scope, Messages, $timeout, Container, ContainerTop, $stat
});
}
var destroyed = false;
var timeout;
$scope.$on('$destroy', function () {
destroyed = true;
$timeout.cancel(timeout);
@@ -204,6 +200,12 @@ function (Pagination, $scope, Messages, $timeout, Container, ContainerTop, $stat
}
return cpuPercent;
}
function setUpdateStatsTimeout() {
if(!destroyed) {
timeout = $timeout(updateStats, 5000);
}
}
});
Container.get({id: $stateParams.id}, function (d) {

View File

@@ -163,57 +163,65 @@
<thead>
<tr>
<th>
<a ui-sref="swarm" ng-click="order('Description.Hostname')">
<a ui-sref="swarm" ng-click="order('Hostname')">
Name
<span ng-show="sortType == 'Description.Hostname' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Description.Hostname' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
<span ng-show="sortType == 'Hostname' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Hostname' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="swarm" ng-click="order('Spec.Role')">
<a ui-sref="swarm" ng-click="order('Role')">
Role
<span ng-show="sortType == 'Spec.Role' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Spec.Role' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
<span ng-show="sortType == 'Role' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Role' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="swarm" ng-click="order('Description.Resources.NanoCPUs')">
<a ui-sref="swarm" ng-click="order('CPUs')">
CPU
<span ng-show="sortType == 'Description.Resources.NanoCPUs' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Description.Resources.NanoCPUs' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
<span ng-show="sortType == 'CPUs' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'CPUs' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="swarm" ng-click="order('Description.Resources.MemoryBytes')">
<a ui-sref="swarm" ng-click="order('Memory')">
Memory
<span ng-show="sortType == 'Description.Resources.MemoryBytes' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Description.Resources.MemoryBytes' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
<span ng-show="sortType == 'Memory' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Memory' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="swarm" ng-click="order('Description.Engine.EngineVersion')">
<a ui-sref="swarm" ng-click="order('EngineVersion')">
Engine
<span ng-show="sortType == 'Description.Engine.EngineVersion' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Description.Engine.EngineVersion' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
<span ng-show="sortType == 'EngineVersion' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'EngineVersion' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.endpoint.apiVersion >= 1.25">
<a ui-sref="swarm" ng-click="order('Addr')">
IP Address
<span ng-show="sortType == 'Addr' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Addr' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="swarm" ng-click="order('node.Status.State')">
<a ui-sref="swarm" ng-click="order('Status')">
Status
<span ng-show="sortType == 'node.Status.State' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'node.Status.State' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
<span ng-show="sortType == 'Status' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Status' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="node in (state.filteredNodes = (nodes | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><a ui-sref="node({id: node.ID})">{{ node.Description.Hostname }}</a></td>
<td>{{ node.Spec.Role }}</td>
<td>{{ node.Description.Resources.NanoCPUs / 1000000000 }}</td>
<td>{{ node.Description.Resources.MemoryBytes|humansize }}</td>
<td>{{ node.Description.Engine.EngineVersion }}</td>
<td><span class="label label-{{ node.Status.State|nodestatusbadge }}">{{ node.Status.State }}</span></td>
<td><a ui-sref="node({id: node.Id})">{{ node.Hostname }}</a></td>
<td>{{ node.Role }}</td>
<td>{{ node.CPUs / 1000000000 }}</td>
<td>{{ node.Memory|humansize }}</td>
<td>{{ node.EngineVersion }}</td>
<td ng-if="applicationState.endpoint.apiVersion >= 1.25">{{ node.Addr }}</td>
<td><span class="label label-{{ node.Status|nodestatusbadge }}">{{ node.Status }}</span></td>
</tr>
</tbody>
</table>

View File

@@ -28,7 +28,9 @@ function ($scope, Info, Version, Node, Pagination) {
$scope.info = d;
if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') {
Node.query({}, function(d) {
$scope.nodes = d;
$scope.nodes = d.map(function (node) {
return new NodeViewModel(node);
});
var CPU = 0, memory = 0;
angular.forEach(d, function(node) {
CPU += node.Description.Resources.NanoCPUs;
@@ -73,7 +75,7 @@ function ($scope, Info, Version, Node, Pagination) {
var node = {};
node.name = info[offset][0];
node.ip = info[offset][1];
node.id = info[offset + 1][1];
node.Id = info[offset + 1][1];
node.status = info[offset + 2][1];
node.containers = info[offset + 3][1];
node.cpu = info[offset + 4][1].split('/')[1];

View File

@@ -10,7 +10,7 @@
<div id="selectedTemplate" class="row" ng-if="state.selectedTemplate">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-custom-header icon="state.selectedTemplate.logo" title="state.selectedTemplate.image">
<rd-widget-custom-header icon="state.selectedTemplate.Logo" title="state.selectedTemplate.Image">
</rd-widget-custom-header>
<rd-widget-body classes="padding">
<form class="form-horizontal">
@@ -27,19 +27,20 @@
</div>
<!-- name-and-network-inputs -->
<div class="form-group">
<label for="image_registry" class="col-sm-2 control-label text-left">Name</label>
<div class="col-sm-4">
<input type="text" class="form-control" ng-model="formValues.name" placeholder="e.g. web (optional)">
<label for="container_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-5">
<input type="text" name="container_name" class="form-control" ng-model="formValues.name" placeholder="e.g. web (optional)">
</div>
<label for="container_network" class="col-sm-2 control-label text-right">Network</label>
<div class="col-sm-4">
<label for="container_network" class="col-sm-2 col-lg-1 control-label text-right">Network</label>
<div class="col-sm-4 col-lg-5">
<select class="form-control" ng-options="net.Name for net in availableNetworks" ng-model="formValues.network">
<option disabled hidden value="">Select a network</option>
</select>
</div>
</div>
<!-- !name-and-network-inputs -->
<div ng-repeat="var in state.selectedTemplate.env" ng-if="!var.set" class="form-group">
<!-- env -->
<div ng-repeat="var in state.selectedTemplate.Env" ng-if="!var.set" class="form-group">
<label for="field_{{ $index }}" class="col-sm-2 control-label text-left">{{ var.label }}</label>
<div class="col-sm-10">
<select ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM' && var.type === 'container'" ng-options="container|containername for container in runningContainers" class="form-control" ng-model="var.value">
@@ -51,6 +52,21 @@
<input ng-if="!var.type || !var.type === 'container'" type="text" class="form-control" ng-model="var.value" id="field_{{ $index }}">
</div>
</div>
<!-- !env -->
<!-- ownership -->
<div class="form-group" ng-if="applicationState.application.authentication">
<div class="col-sm-12">
<label for="ownership" class="control-label text-left">
Ownership
<portainer-tooltip position="bottom" message="When setting the ownership value to private, only you and the administrators will be able to see and manage this object. When choosing public, everybody will be able to access it."></portainer-tooltip>
</label>
<div class="btn-group btn-group-sm" style="margin-left: 20px;">
<label class="btn btn-default" ng-model="formValues.Ownership" uib-btn-radio="'private'">Private</label>
<label class="btn btn-default" ng-model="formValues.Ownership" uib-btn-radio="'public'">Public</label>
</div>
</div>
</div>
<!-- !ownership -->
<div class="form-group">
<div class="col-sm-12">
<a class="small interactive" ng-if="!state.showAdvancedOptions" ng-click="state.showAdvancedOptions = true;">
@@ -61,43 +77,128 @@
</a>
</div>
</div>
<div class="form-group" ng-if="state.showAdvancedOptions">
<label for="container_ports" class="col-sm-1 control-label text-left">Port mapping</label>
<div class="col-sm-11" style="margin-top: 5px;">
<span class="label label-default interactive" ng-click="addPortBinding()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional port
</span>
<!-- advanced-options -->
<div ng-if="state.showAdvancedOptions">
<!-- port-mapping -->
<div class="form-group" >
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Port mapping</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addPortBinding()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional port
</span>
</div>
<div class="col-sm-12" style="margin-top: 10px" ng-if="state.selectedTemplate.Ports.length > 0">
<span class="small text-muted">Portainer will automatically assign a port if you leave the host port empty.</span>
</div>
<!-- !port-mapping -->
<!-- port-mapping-input-list -->
<div class="col-sm-12">
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="portBinding in state.selectedTemplate.Ports" style="margin-top: 2px;">
<!-- host-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="portBinding.hostPort" placeholder="e.g. 80 or 1.2.3.4:80 (optional)">
</div>
<!-- !host-port -->
<span style="margin: 0 10px 0 10px;">
<i class="fa fa-long-arrow-right" aria-hidden="true"></i>
</span>
<!-- container-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="portBinding.containerPort" placeholder="e.g. 80">
</div>
<!-- !container-port -->
<!-- protocol-actions -->
<div class="input-group col-sm-3 input-group-sm">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="portBinding.protocol" uib-btn-radio="'tcp'">TCP</label>
<label class="btn btn-primary" ng-model="portBinding.protocol" uib-btn-radio="'udp'">UDP</label>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removePortBinding($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
<!-- !protocol-actions -->
</div>
</div>
</div>
</div>
<!-- port-mapping-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div ng-repeat="portBinding in formValues.ports" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="portBinding.hostPort" placeholder="e.g. 80 or 1.2.3.4:80 (optional)">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="portBinding.containerPort" placeholder="e.g. 80">
</div>
<div class="input-group col-sm-1 input-group-sm">
<select class="form-control" ng-model="portBinding.protocol">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removePortBinding($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
<!-- !port-mapping-input-list -->
<!-- volume-mapping -->
<div class="form-group" >
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Volume mapping</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addVolume()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional volume
</span>
</div>
<div class="col-sm-12" style="margin-top: 10px" ng-if="state.selectedTemplate.Volumes.length > 0">
<span class="small text-muted">Portainer will automatically create and map a local volume when using the <b>auto</b> option.</span>
</div>
<div ng-repeat="volume in state.selectedTemplate.Volumes">
<div class="col-sm-12" style="margin-top: 10px;">
<!-- volume-line1 -->
<div class="col-sm-12 form-inline">
<!-- container-path -->
<div class="input-group input-group-sm col-sm-6">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="volume.containerPath" placeholder="e.g. /path/in/container">
</div>
<!-- !container-path -->
<!-- volume-type -->
<div class="input-group col-sm-5" style="margin-left: 5px;">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'auto'" ng-click="volume.name = ''">Auto</label>
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.name = ''">Bind</label>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeVolume($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
<!-- !volume-type -->
</div>
<!-- !volume-line1 -->
<!-- volume-line2 -->
<div class="col-sm-12 form-inline" style="margin-top: 5px;" ng-if="volume.type !== 'auto'">
<i class="fa fa-long-arrow-right" aria-hidden="true"></i>
<!-- volume -->
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'volume'">
<span class="input-group-addon">volume</span>
<select class="form-control" ng-model="volume.name">
<option selected disabled hidden value="">Select a volume</option>
<option ng-repeat="vol in availableVolumes" ng-value="vol.Name">{{ vol.Name|truncate:30}}</option>
</select>
</div>
<!-- !volume -->
<!-- bind -->
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'bind'">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="volume.name" placeholder="e.g. /path/on/host">
</div>
<!-- !bind -->
<!-- read-only -->
<div class="input-group input-group-sm col-sm-5" style="margin-left: 5px;">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="volume.readOnly" uib-btn-radio="false">Writable</label>
<label class="btn btn-primary" ng-model="volume.readOnly" uib-btn-radio="true">Read-only</label>
</div>
</div>
<!-- !read-only -->
</div>
<!-- !volume-line2 -->
</div>
</div>
</div>
<!-- !port-mapping-input-list -->
<!-- !volume-mapping -->
</div>
<!-- !port-mapping -->
<!-- !advanced-options -->
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-default btn-sm" ng-disabled="!formValues.network" ng-click="createTemplate()">Create</button>
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.network" ng-click="createTemplate()">Create</button>
<i id="createContainerSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
@@ -125,9 +226,9 @@
<rd-widget-body classes="padding">
<div class="template-list">
<div dir-paginate="tpl in templates | itemsPerPage: state.pagination_count" class="container-template hvr-underline-from-center" id="template_{{ tpl.index }}" ng-click="selectTemplate(tpl.index)">
<img class="logo" ng-src="{{ tpl.logo }}" />
<div class="title">{{ tpl.title }}</div>
<div class="description">{{ tpl.description }}</div>
<img class="logo" ng-src="{{ tpl.Logo }}" />
<div class="title">{{ tpl.Title }}</div>
<div class="description">{{ tpl.Description }}</div>
</div>
<div ng-if="!templates" class="text-center text-muted">
Loading...

View File

@@ -1,241 +1,157 @@
angular.module('templates', [])
.controller('TemplatesController', ['$scope', '$q', '$state', '$filter', '$anchorScroll', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'Templates', 'TemplateHelper', 'Messages', 'Pagination',
function ($scope, $q, $state, $filter, $anchorScroll, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, Templates, TemplateHelper, Messages, Pagination) {
.controller('TemplatesController', ['$scope', '$q', '$state', '$anchorScroll', 'Config', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Messages', 'Pagination', 'ResourceControlService', 'Authentication',
function ($scope, $q, $state, $anchorScroll, Config, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Messages, Pagination, ResourceControlService, Authentication) {
$scope.state = {
selectedTemplate: null,
showAdvancedOptions: false,
pagination_count: Pagination.getPaginationCount('templates')
};
$scope.formValues = {
Ownership: $scope.applicationState.application.authentication ? 'private' : '',
network: "",
name: "",
ports: []
};
var selectedItem = -1;
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('templates', $scope.state.pagination_count);
};
$scope.addVolume = function () {
$scope.state.selectedTemplate.Volumes.push({ containerPath: '', name: '', readOnly: false, type: 'auto' });
};
$scope.removeVolume = function(index) {
$scope.state.selectedTemplate.Volumes.splice(index, 1);
};
$scope.addPortBinding = function() {
$scope.formValues.ports.push({ hostPort: '', containerPort: '', protocol: 'tcp' });
$scope.state.selectedTemplate.Ports.push({ hostPort: '', containerPort: '', protocol: 'tcp' });
};
$scope.removePortBinding = function(index) {
$scope.formValues.ports.splice(index, 1);
$scope.state.selectedTemplate.Ports.splice(index, 1);
};
// TODO: centralize, already present in createContainerController
function createContainer(config) {
Container.create(config, function (d) {
if (d.message) {
$('#createContainerSpinner').hide();
Messages.error('Error', {}, d.message);
} else {
Container.start({id: d.Id}, {}, function (cd) {
if (cd.message) {
$('#createContainerSpinner').hide();
Messages.error('Error', {}, cd.message);
} else {
$('#createContainerSpinner').hide();
Messages.send('Container Started', d.Id);
$state.go('containers', {}, {reload: true});
}
}, function (e) {
$('#createContainerSpinner').hide();
Messages.error("Failure", e, 'Unable to start container');
});
}
}, function (e) {
$('#createContainerSpinner').hide();
Messages.error("Failure", e, 'Unable to create container');
});
}
// TODO: centralize, already present in createContainerController
function pullImageAndCreateContainer(imageConfig, containerConfig) {
Image.create(imageConfig, function (data) {
var err = data.length > 0 && data[data.length - 1].hasOwnProperty('error');
if (err) {
var detail = data[data.length - 1];
$('#createContainerSpinner').hide();
Messages.error("Error", {}, detail.error);
} else {
createContainer(containerConfig);
}
}, function (e) {
$('#createContainerSpinner').hide();
Messages.error("Failure", e, "Unable to pull image");
});
}
function getInitialConfiguration() {
return {
Env: [],
OpenStdin: false,
Tty: false,
ExposedPorts: {},
HostConfig: {
RestartPolicy: {
Name: 'no'
},
PortBindings: {},
Binds: [],
NetworkMode: $scope.formValues.network.Name,
Privileged: false
},
Volumes: {},
name: $scope.formValues.name
};
}
function preparePortBindings(config, ports) {
var bindings = {};
ports.forEach(function (portBinding) {
if (portBinding.containerPort) {
var key = portBinding.containerPort + "/" + portBinding.protocol;
var binding = {};
if (portBinding.hostPort && portBinding.hostPort.indexOf(':') > -1) {
var hostAndPort = portBinding.hostPort.split(':');
binding.HostIp = hostAndPort[0];
binding.HostPort = hostAndPort[1];
} else {
binding.HostPort = portBinding.hostPort;
}
bindings[key] = [binding];
config.ExposedPorts[key] = {};
}
});
config.HostConfig.PortBindings = bindings;
}
function createConfigFromTemplate(template) {
var containerConfig = getInitialConfiguration();
containerConfig.Image = template.image;
if (template.env) {
template.env.forEach(function (v) {
if (v.value || v.set) {
var val;
if (v.type && v.type === 'container') {
if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' && $scope.formValues.network.Scope === 'global') {
val = $filter('swarmcontainername')(v.value);
} else {
var container = v.value;
val = container.NetworkSettings.Networks[Object.keys(container.NetworkSettings.Networks)[0]].IPAddress;
}
} else {
val = v.set ? v.set : v.value;
}
containerConfig.Env.push(v.name + "=" + val);
}
});
}
preparePortBindings(containerConfig, $scope.formValues.ports);
prepareImageConfig(containerConfig, template);
return containerConfig;
}
function prepareImageConfig(config, template) {
var image = template.image;
var registry = template.registry || '';
var imageConfig = ImageHelper.createImageConfigForContainer(image, registry);
config.Image = imageConfig.fromImage + ':' + imageConfig.tag;
$scope.imageConfig = imageConfig;
}
function prepareVolumeQueries(template, containerConfig) {
var volumeQueries = [];
if (template.volumes) {
template.volumes.forEach(function (vol) {
volumeQueries.push(
Volume.create({}, function (d) {
if (d.message) {
Messages.error("Unable to create volume", {}, d.message);
} else {
Messages.send("Volume created", d.Name);
containerConfig.Volumes[vol] = {};
containerConfig.HostConfig.Binds.push(d.Name + ':' + vol);
}
}, function (e) {
Messages.error("Failure", e, "Unable to create volume");
}).$promise
);
});
}
return volumeQueries;
}
$scope.createTemplate = function() {
$('#createContainerSpinner').show();
var template = $scope.state.selectedTemplate;
var containerConfig = createConfigFromTemplate(template);
var createVolumeQueries = prepareVolumeQueries(template, containerConfig);
$q.all(createVolumeQueries).then(function (d) {
pullImageAndCreateContainer($scope.imageConfig, containerConfig);
var templateConfiguration = createTemplateConfiguration(template);
var generatedVolumeCount = TemplateHelper.determineRequiredGeneratedVolumeCount(template.Volumes);
VolumeService.createXAutoGeneratedLocalVolumes(generatedVolumeCount)
.then(function success(data) {
var volumeResourceControlQueries = [];
if ($scope.formValues.Ownership === 'private') {
angular.forEach(data, function (volume) {
volumeResourceControlQueries.push(ResourceControlService.setVolumeResourceControl(Authentication.getUserDetails().ID, volume.Name));
});
}
TemplateService.updateContainerConfigurationWithVolumes(templateConfiguration, template, data);
return $q.all(volumeResourceControlQueries)
.then(function success() {
return ImageService.pullImage(template.Image, template.Registry);
});
})
.then(function success(data) {
return ContainerService.createAndStartContainer(templateConfiguration);
})
.then(function success(data) {
Messages.send('Container Started', data.Id);
if ($scope.formValues.Ownership === 'private') {
ResourceControlService.setContainerResourceControl(Authentication.getUserDetails().ID, data.Id)
.then(function success(data) {
$state.go('containers', {}, {reload: true});
});
} else {
$state.go('containers', {}, {reload: true});
}
})
.catch(function error(err) {
Messages.error('Failure', err, err.msg);
})
.finally(function final() {
$('#createContainerSpinner').hide();
});
};
$scope.selectTemplate = function(id) {
$('#template_' + id).toggleClass("container-template--selected");
if (selectedItem === id) {
selectedItem = -1;
$scope.state.selectedTemplate = null;
var selectedItem = -1;
$scope.selectTemplate = function(idx) {
$('#template_' + idx).toggleClass("container-template--selected");
if (selectedItem === idx) {
unselectTemplate();
} else {
$('#template_' + selectedItem).toggleClass("container-template--selected");
selectedItem = id;
var selectedTemplate = $scope.templates[id];
$scope.state.selectedTemplate = selectedTemplate;
$scope.formValues.ports = selectedTemplate.ports ? TemplateHelper.getPortBindings(selectedTemplate.ports) : [];
$anchorScroll('selectedTemplate');
selectTemplate(idx);
}
};
function unselectTemplate() {
selectedItem = -1;
$scope.state.selectedTemplate = null;
}
function selectTemplate(idx) {
$('#template_' + selectedItem).toggleClass("container-template--selected");
selectedItem = idx;
var selectedTemplate = $scope.templates[idx];
$scope.state.selectedTemplate = selectedTemplate;
if (selectedTemplate.Network) {
$scope.formValues.network = _.find($scope.availableNetworks, function(o) { return o.Name === selectedTemplate.Network; });
} else {
$scope.formValues.network = _.find($scope.availableNetworks, function(o) { return o.Name === "bridge"; });
}
$anchorScroll('selectedTemplate');
}
function createTemplateConfiguration(template) {
var network = $scope.formValues.network;
var name = $scope.formValues.name;
var containerMapping = determineContainerMapping(network);
return TemplateService.createTemplateConfiguration(template, name, network, containerMapping);
}
function determineContainerMapping(network) {
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
var containerMapping = 'BY_CONTAINER_IP';
if (endpointProvider === 'DOCKER_SWARM' && network.Scope === 'global') {
containerMapping = 'BY_SWARM_CONTAINER_NAME';
} else if (network.Name !== "bridge") {
containerMapping = 'BY_CONTAINER_NAME';
}
return containerMapping;
}
function filterNetworksBasedOnProvider(networks) {
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
if (endpointProvider === 'DOCKER_SWARM' || endpointProvider === 'DOCKER_SWARM_MODE') {
networks = NetworkService.filterGlobalNetworks(networks);
$scope.globalNetworkCount = networks.length;
NetworkService.addPredefinedLocalNetworks(networks);
}
return networks;
}
function initTemplates() {
Templates.get(function (data) {
$scope.templates = data.map(function(tpl,index){
tpl.index = index;
return tpl;
Config.$promise.then(function (c) {
$q.all({
templates: TemplateService.getTemplates(),
containers: ContainerService.getContainers(0, c.hiddenLabels),
networks: NetworkService.getNetworks(),
volumes: VolumeService.getVolumes()
})
.then(function success(data) {
$scope.templates = data.templates;
$scope.runningContainers = data.containers;
$scope.availableNetworks = filterNetworksBasedOnProvider(data.networks);
$scope.availableVolumes = data.volumes.Volumes;
})
.catch(function error(err) {
$scope.templates = [];
Messages.error("Failure", err, "An error occured during apps initialization.");
})
.finally(function final(){
$('#loadTemplatesSpinner').hide();
});
$('#loadTemplatesSpinner').hide();
}, function (e) {
$('#loadTemplatesSpinner').hide();
Messages.error("Failure", e, "Unable to retrieve apps list");
$scope.templates = [];
});
}
Config.$promise.then(function (c) {
var containersToHideLabels = c.hiddenLabels;
Network.query({}, function (d) {
var networks = d;
if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') {
networks = d.filter(function (network) {
if (network.Scope === 'global') {
return network;
}
});
$scope.globalNetworkCount = networks.length;
networks.push({Scope: "local", Name: "bridge"});
networks.push({Scope: "local", Name: "host"});
networks.push({Scope: "local", Name: "none"});
} else {
$scope.formValues.network = _.find(networks, function(o) { return o.Name === "bridge"; });
}
$scope.availableNetworks = networks;
}, function (e) {
Messages.error("Failure", e, "Unable to retrieve networks");
});
Container.query({all: 0}, function (d) {
var containers = d;
if (containersToHideLabels) {
containers = ContainerHelper.hideContainers(d, containersToHideLabels);
}
$scope.runningContainers = containers;
}, function (e) {
Messages.error("Failure", e, "Unable to retrieve running containers");
});
initTemplates();
});
initTemplates();
}]);

View File

@@ -0,0 +1,84 @@
<rd-header>
<rd-header-title title="User details">
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="users">Users</a> > <a ui-sref="user({id: user.Id})">{{ user.Username }}</a>
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-user" title="User details"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Name</td>
<td>
{{ user.Username }}
<button class="btn btn-xs btn-danger" ng-click="deleteUser()"><i class="fa fa-trash space-right" aria-hidden="true"></i>Delete this user</button>
</td>
</tr>
<td colspan="2">
<label for="permissions" class="control-label text-left">
Administrator
<portainer-tooltip position="bottom" message="Administrators have access to Portainer settings management as well as full control over all defined endpoints and their resources."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="formValues.Administrator" ng-change="updatePermissions()"><i></i>
</label>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-lock" title="Change user password"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal" style="margin-top: 15px;">
<!-- new-password-input -->
<div class="form-group">
<label for="new_password" class="col-sm-2 control-label text-left">New password</label>
<div class="col-sm-8">
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
<input type="password" class="form-control" ng-model="formValues.newPassword" id="new_password">
</div>
</div>
</div>
<!-- !new-password-input -->
<!-- confirm-password-input -->
<div class="form-group">
<label for="confirm_password" class="col-sm-2 control-label text-left">Confirm password</label>
<div class="col-sm-8">
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
<input type="password" class="form-control" ng-model="formValues.confirmPassword" id="confirm_password">
<span class="input-group-addon"><i ng-class="{true: 'fa fa-check green-icon', false: 'fa fa-times red-icon'}[formValues.newPassword !== '' && formValues.newPassword === formValues.confirmPassword]" aria-hidden="true"></i></span>
</div>
</div>
</div>
<!-- !confirm-password-input -->
<div class="form-group">
<div class="col-sm-2">
<button type="submit" class="btn btn-primary btn-sm" ng-disabled="formValues.newPassword === '' || formValues.newPassword !== formValues.confirmPassword" ng-click="updatePassword()">Update password</button>
</div>
<div class="col-sm-10">
<p class="pull-left text-danger" ng-if="state.updatePasswordError" style="margin: 5px;">
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> {{ state.updatePasswordError }}
</p>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -0,0 +1,89 @@
angular.module('user', [])
.controller('UserController', ['$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Messages',
function ($scope, $state, $stateParams, UserService, ModalService, Messages) {
$scope.state = {
updatePasswordError: '',
};
$scope.formValues = {
newPassword: '',
confirmPassword: '',
Administrator: false,
};
$scope.deleteUser = function() {
ModalService.confirmDeletion(
'Do you want to delete this user? This user will not be able to login into Portainer anymore.',
function onConfirm(confirmed) {
if(!confirmed) { return; }
deleteUser();
}
);
};
$scope.updatePermissions = function() {
$('#loadingViewSpinner').show();
var role = $scope.formValues.Administrator ? 1 : 2;
UserService.updateUser($scope.user.Id, undefined, role)
.then(function success(data) {
var newRole = role === 1 ? 'administrator' : 'user';
Messages.send('Permissions successfully updated', $scope.user.Username + ' is now ' + newRole);
$state.reload();
})
.catch(function error(err) {
Messages.error("Failure", err, 'Unable to update user permissions');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
};
$scope.updatePassword = function() {
$('#loadingViewSpinner').show();
UserService.updateUser($scope.user.Id, $scope.formValues.newPassword, undefined)
.then(function success(data) {
Messages.send('Password successfully updated');
$state.reload();
})
.catch(function error(err) {
$scope.state.updatePasswordError = 'Unable to update password';
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
};
function deleteUser() {
$('#loadingViewSpinner').show();
UserService.deleteUser($scope.user.Id)
.then(function success(data) {
Messages.send('User successfully deleted', $scope.user.Username);
$state.go('users');
})
.catch(function error(err) {
Messages.error("Failure", err, 'Unable to remove user');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
function getUser() {
$('#loadingViewSpinner').show();
UserService.user($stateParams.id)
.then(function success(data) {
var user = new UserViewModel(data);
$scope.user = user;
$scope.formValues.Administrator = user.RoleId === 1 ? true : false;
})
.catch(function error(err) {
Messages.error("Failure", err, 'Unable to retrieve user information');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
getUser();
}]);

View File

@@ -0,0 +1,160 @@
<rd-header>
<rd-header-title title="Users">
<a data-toggle="tooltip" title="Refresh" ui-sref="users" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
<i id="loadUsersSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
</rd-header-title>
<rd-header-content>User management</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-plus" title="Add a new user">
</rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<!-- name-input -->
<div class="form-group">
<label for="username" class="col-sm-2 control-label text-left">Username</label>
<div class="col-sm-8">
<div class="input-group">
<input type="text" class="form-control" id="username" ng-model="formValues.Username" ng-change="checkUsernameValidity()" placeholder="e.g. jdoe">
<span class="input-group-addon"><i ng-class="{true: 'fa fa-check green-icon', false: 'fa fa-times red-icon'}[state.validUsername]" aria-hidden="true"></i></span>
</div>
</div>
</div>
<!-- !name-input -->
<!-- new-password-input -->
<div class="form-group">
<label for="password" class="col-sm-2 control-label text-left">Password</label>
<div class="col-sm-8">
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
<input type="password" class="form-control" ng-model="formValues.Password" id="password">
</div>
</div>
</div>
<!-- !new-password-input -->
<!-- confirm-password-input -->
<div class="form-group">
<label for="confirm_password" class="col-sm-2 control-label text-left">Confirm password</label>
<div class="col-sm-8">
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
<input type="password" class="form-control" ng-model="formValues.ConfirmPassword" id="confirm_password">
<span class="input-group-addon"><i ng-class="{true: 'fa fa-check green-icon', false: 'fa fa-times red-icon'}[formValues.Password !== '' && formValues.Password === formValues.ConfirmPassword]" aria-hidden="true"></i></span>
</div>
</div>
</div>
<!-- !confirm-password-input -->
<!-- role-checkbox -->
<div class="form-group">
<div class="col-sm-12">
<label for="permissions" class="control-label text-left">
Administrator
<portainer-tooltip position="bottom" message="Administrators have access to Portainer settings management as well as full control over all defined endpoints and their resources."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="formValues.Administrator"><i></i>
</label>
</div>
</div>
<!-- !role-checkbox -->
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted">Note: non-administrator users do not have access to any endpoint by default. Head over the <a ui-sref="endpoints">endpoints view</a> to manage their accesses.</span>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!state.validUsername || formValues.Username === '' || formValues.Password === '' || formValues.Password !== formValues.ConfirmPassword" ng-click="addUser()"><i class="fa fa-user-plus" aria-hidden="true"></i> Add user</button>
<i id="createUserSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
<span class="text-danger" ng-if="state.userCreationError" style="margin: 5px;">
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> {{ state.userCreationError }}
</span>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-user" title="Users">
<div class="pull-right">
Items per page:
<select ng-model="state.pagination_count" ng-change="changePaginationCount()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-lg-12">
<div class="pull-left">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ui-sref="users" ng-click="order('Username')">
Name
<span ng-show="sortType == 'Username' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Username' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="users" ng-click="order('RoleName')">
Role
<span ng-show="sortType == 'RoleName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'RoleName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th></th>
</tr>
</thead>
<tbody>
<tr dir-paginate="user in (state.filteredUsers = (users | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="user.Checked" ng-change="selectItem(user)" /></td>
<td>{{ user.Username }}</td>
<td>
{{ user.RoleName }}
<i class="fa" ng-class="user.RoleId === 1 ? 'fa-user-circle-o' : 'fa-user'" aria-hidden="true" style="margin-left: 2px;"></i>
</td>
<td>
<a ui-sref="user({id: user.Id})"><i class="fa fa-pencil-square-o" aria-hidden="true"></i> Edit</a>
</td>
</tr>
<tr ng-if="!users">
<td colspan="4" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="users.length == 0">
<td colspan="4" class="text-center text-muted">No users available.</td>
</tr>
</tbody>
</table>
<div ng-if="users" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
<rd-widget>
</div>
</div>

View File

@@ -0,0 +1,132 @@
angular.module('users', [])
.controller('UsersController', ['$scope', '$state', 'UserService', 'ModalService', 'Messages', 'Pagination',
function ($scope, $state, UserService, ModalService, Messages, Pagination) {
$scope.state = {
userCreationError: '',
selectedItemCount: 0,
validUsername: false,
pagination_count: Pagination.getPaginationCount('users')
};
$scope.sortType = 'RoleName';
$scope.sortReverse = false;
$scope.formValues = {
Username: '',
Password: '',
ConfirmPassword: '',
Administrator: false,
};
$scope.order = function(sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('endpoints', $scope.state.pagination_count);
};
$scope.selectItems = function (allSelected) {
angular.forEach($scope.state.filteredUsers, function (user) {
if (user.Checked !== allSelected) {
user.Checked = allSelected;
$scope.selectItem(user);
}
});
};
$scope.selectItem = function (item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
} else {
$scope.state.selectedItemCount--;
}
};
$scope.checkUsernameValidity = function() {
var valid = true;
for (var i = 0; i < $scope.users.length; i++) {
if ($scope.formValues.Username === $scope.users[i].Username) {
valid = false;
break;
}
}
$scope.state.validUsername = valid;
$scope.state.userCreationError = valid ? '' : 'Username already taken';
};
$scope.addUser = function() {
$scope.state.userCreationError = '';
var username = $scope.formValues.Username;
var password = $scope.formValues.Password;
var role = $scope.formValues.Administrator ? 1 : 2;
UserService.createUser(username, password, role)
.then(function success(data) {
Messages.send("User created", username);
$state.reload();
})
.catch(function error(err) {
$scope.state.userCreationError = err.msg;
})
.finally(function final() {
});
};
function deleteSelectedUsers() {
$('#loadUsersSpinner').show();
var counter = 0;
var complete = function () {
counter = counter - 1;
if (counter === 0) {
$('#loadUsersSpinner').hide();
}
};
angular.forEach($scope.users, function (user) {
if (user.Checked) {
counter = counter + 1;
UserService.deleteUser(user.Id)
.then(function success(data) {
var index = $scope.users.indexOf(user);
$scope.users.splice(index, 1);
Messages.send('User successfully deleted', user.Username);
})
.catch(function error(err) {
Messages.error("Failure", err, 'Unable to remove user');
})
.finally(function final() {
complete();
});
}
});
}
$scope.removeAction = function () {
ModalService.confirmDeletion(
'Do you want to delete the selected users? They will not be able to login into Portainer anymore.',
function onConfirm(confirmed) {
if(!confirmed) { return; }
deleteSelectedUsers();
}
);
};
function fetchUsers() {
$('#loadUsersSpinner').show();
UserService.users()
.then(function success(data) {
$scope.users = data.map(function(user) {
return new UserViewModel(user);
});
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to retrieve users");
$scope.users = [];
})
.finally(function final() {
$('#loadUsersSpinner').hide();
});
}
fetchUsers();
}]);

View File

@@ -25,7 +25,7 @@
<rd-widget-taskbar classes="col-lg-12">
<div class="pull-left">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<a class="btn btn-default" type="button" ui-sref="actions.create.volume">Add volume</a>
<a class="btn btn-primary" type="button" ui-sref="actions.create.volume"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add volume</a>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
@@ -60,6 +60,13 @@
<span ng-show="sortType == 'Mountpoint' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">
<a ui-sref="volumes" ng-click="order('Metadata.ResourceControl.OwnerId')">
Ownership
<span ng-show="sortType == 'Metadata.ResourceControl.OwnerId' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Metadata.ResourceControl.OwnerId' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
@@ -68,12 +75,33 @@
<td>{{ volume.Name|truncate:50 }}</td>
<td>{{ volume.Driver }}</td>
<td>{{ volume.Mountpoint }}</td>
<td ng-if="applicationState.application.authentication">
<span ng-if="user.role === 1 && volume.Metadata.ResourceControl">
<i class="fa fa-eye-slash" aria-hidden="true"></i>
<span ng-if="volume.Metadata.ResourceControl.OwnerId === user.ID">
Private
</span>
<span ng-if="volume.Metadata.ResourceControl.OwnerId !== user.ID">
Private <span ng-if="volume.Owner">(owner: {{ volume.Owner }})</span>
</span>
<a ng-click="switchOwnership(volume)" class="interactive"><i class="fa fa-eye" aria-hidden="true" style="margin-left: 7px;"></i> Switch to public</a>
</span>
<span ng-if="user.role !== 1 && volume.Metadata.ResourceControl.OwnerId === user.ID">
<i class="fa fa-eye-slash" aria-hidden="true"></i>
Private
<a ng-click="switchOwnership(volume)" class="interactive"><i class="fa fa-eye" aria-hidden="true" style="margin-left: 7px;"></i> Switch to public</a>
</span>
<span ng-if="!volume.Metadata.ResourceControl">
<i class="fa fa-eye" aria-hidden="true"></i>
Public
</span>
</td>
</tr>
<tr ng-if="!volumes">
<td colspan="4" class="text-center text-muted">Loading...</td>
<td colspan="6" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="volumes.length == 0">
<td colspan="4" class="text-center text-muted">No volumes available.</td>
<td colspan="6" class="text-center text-muted">No volumes available.</td>
</tr>
</tbody>
</table>

View File

@@ -1,6 +1,6 @@
angular.module('volumes', [])
.controller('VolumesController', ['$scope', '$state', 'Volume', 'Messages', 'Pagination',
function ($scope, $state, Volume, Messages, Pagination) {
.controller('VolumesController', ['$scope', '$state', 'Volume', 'Messages', 'Pagination', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService',
function ($scope, $state, Volume, Messages, Pagination, ModalService, Authentication, ResourceControlService, UserService) {
$scope.state = {};
$scope.state.pagination_count = Pagination.getPaginationCount('volumes');
$scope.state.selectedItemCount = 0;
@@ -10,6 +10,24 @@ function ($scope, $state, Volume, Messages, Pagination) {
Name: ''
};
function removeVolumeResourceControl(volume) {
ResourceControlService.removeVolumeResourceControl(volume.Metadata.ResourceControl.OwnerId, volume.Name)
.then(function success() {
delete volume.Metadata.ResourceControl;
Messages.send('Ownership changed to public', volume.Name);
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to change volume ownership");
});
}
$scope.switchOwnership = function(volume) {
ModalService.confirmVolumeOwnershipChange(function (confirmed) {
if(!confirmed) { return; }
removeVolumeResourceControl(volume);
});
};
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('volumes', $scope.state.pagination_count);
};
@@ -52,9 +70,21 @@ function ($scope, $state, Volume, Messages, Pagination) {
if (d.message) {
Messages.error("Unable to remove volume", {}, d.message);
} else {
Messages.send("Volume deleted", volume.Name);
var index = $scope.volumes.indexOf(volume);
$scope.volumes.splice(index, 1);
if (volume.Metadata && volume.Metadata.ResourceControl) {
ResourceControlService.removeVolumeResourceControl(volume.Metadata.ResourceControl.OwnerId, volume.Name)
.then(function success() {
Messages.send("Volume deleted", volume.Name);
var index = $scope.volumes.indexOf(volume);
$scope.volumes.splice(index, 1);
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to remove volume ownership");
});
} else {
Messages.send("Volume deleted", volume.Name);
var index = $scope.volumes.indexOf(volume);
$scope.volumes.splice(index, 1);
}
}
complete();
}, function (e) {
@@ -65,11 +95,45 @@ function ($scope, $state, Volume, Messages, Pagination) {
});
};
function mapUsersToVolumes(users) {
angular.forEach($scope.volumes, function (volume) {
if (volume.Metadata) {
var volumeRC = volume.Metadata.ResourceControl;
if (volumeRC && volumeRC.OwnerId !== $scope.user.ID) {
angular.forEach(users, function (user) {
if (volumeRC.OwnerId === user.Id) {
volume.Owner = user.Username;
}
});
}
}
});
}
function fetchVolumes() {
$('#loadVolumesSpinner').show();
var userDetails = Authentication.getUserDetails();
$scope.user = userDetails;
Volume.query({}, function (d) {
$scope.volumes = d.Volumes || [];
$('#loadVolumesSpinner').hide();
var volumes = d.Volumes || [];
$scope.volumes = volumes.map(function (v) {
return new VolumeViewModel(v);
});
if (userDetails.role === 1) {
UserService.users()
.then(function success(data) {
mapUsersToVolumes(data);
})
.catch(function error(err) {
Messages.error("Failure", err, "Unable to retrieve users");
})
.finally(function final() {
$('#loadVolumesSpinner').hide();
});
} else {
$('#loadVolumesSpinner').hide();
}
}, function (e) {
$('#loadVolumesSpinner').hide();
Messages.error("Failure", e, "Unable to retrieve volumes");

View File

@@ -1,11 +1,14 @@
angular
.module('portainer')
.directive('rdHeaderContent', function rdHeaderContent() {
.directive('rdHeaderContent', ['Authentication', function rdHeaderContent(Authentication) {
var directive = {
requires: '^rdHeader',
transclude: true,
template: '<div class="breadcrumb-links"><div class="pull-left" ng-transclude></div><div class="pull-right"><a ui-sref="auth({logout: true})" class="text-danger" style="margin-right: 25px;"><u>log out <i class="fa fa-sign-out" aria-hidden="true"></i></u></a></div></div>',
link: function (scope, iElement, iAttrs) {
scope.username = Authentication.getUserDetails().username;
},
template: '<div class="breadcrumb-links"><div class="pull-left" ng-transclude></div><div class="pull-right" ng-if="username"><a ui-sref="auth({logout: true})" class="text-danger" style="margin-right: 25px;"><u>log out <i class="fa fa-sign-out" aria-hidden="true"></i></u></a></div></div>',
restrict: 'E'
};
return directive;
});
}]);

View File

@@ -1,16 +1,16 @@
angular
.module('portainer')
.directive('rdHeaderTitle', ['$rootScope', function rdHeaderTitle($rootScope) {
.directive('rdHeaderTitle', ['Authentication', function rdHeaderTitle(Authentication) {
var directive = {
requires: '^rdHeader',
scope: {
title: '@'
},
link: function (scope, iElement, iAttrs) {
scope.username = $rootScope.username;
scope.username = Authentication.getUserDetails().username;
},
transclude: true,
template: '<div class="page white-space-normal">{{title}}<span class="header_title_content" ng-transclude></span><span class="pull-right user-box"><i class="fa fa-user-circle-o" aria-hidden="true"></i> {{username}}</span></div>',
template: '<div class="page white-space-normal">{{title}}<span class="header_title_content" ng-transclude></span><span class="pull-right user-box" ng-if="username"><i class="fa fa-user-circle-o" aria-hidden="true"></i> {{username}}</span></div>',
restrict: 'E'
};
return directive;

13
app/directives/tooltip.js Normal file
View File

@@ -0,0 +1,13 @@
angular
.module('portainer')
.directive('portainerTooltip', [function portainerTooltip() {
var directive = {
scope: {
message: '@',
position: '@'
},
template: '<span class="interactive" tooltip-placement="{{position}}" tooltip-class="portainer-tooltip" uib-tooltip="{{message}}"><i class="fa fa-question-circle tooltip-icon" aria-hidden="true"></i></span>',
restrict: 'E'
};
return directive;
}]);

View File

@@ -5,10 +5,11 @@ angular
requires: '^rdWidget',
scope: {
title: '@',
icon: '@'
icon: '@',
classes: '@?'
},
transclude: true,
template: '<div class="widget-header"><div class="row"><span class="pull-left"><i class="fa" ng-class="icon"></i> {{title}} </span><span class="pull-right col-xs-6 col-sm-4" ng-transclude></span></div></div>',
template: '<div class="widget-header"><div class="row"><span ng-class="classes" class="pull-left"><i class="fa" ng-class="icon"></i> {{title}} </span><span ng-class="classes" class="pull-right" ng-transclude></span></div></div>',
restrict: 'E'
};
return directive;

View File

@@ -1,231 +1,240 @@
angular.module('portainer.filters', [])
.filter('truncate', function () {
'use strict';
return function (text, length, end) {
if (isNaN(length)) {
length = 10;
}
if (end === undefined) {
end = '...';
}
if (text.length <= length || text.length - end.length <= length) {
return text;
}
else {
return String(text).substring(0, length - end.length) + end;
}
};
})
.filter('taskstatusbadge', function () {
'use strict';
return function (text) {
var status = _.toLower(text);
if (status.indexOf('new') !== -1 || status.indexOf('allocated') !== -1 ||
status.indexOf('assigned') !== -1 || status.indexOf('accepted') !== -1) {
return 'info';
} else if (status.indexOf('pending') !== -1) {
return 'warning';
} else if (status.indexOf('shutdown') !== -1 || status.indexOf('failed') !== -1 ||
status.indexOf('rejected') !== -1) {
return 'danger';
} else if (status.indexOf('complete') !== -1) {
return 'primary';
}
return 'success';
};
})
.filter('containerstatusbadge', function () {
'use strict';
return function (text) {
var status = _.toLower(text);
if (status.indexOf('paused') !== -1) {
return 'warning';
} else if (status.indexOf('created') !== -1) {
return 'info';
} else if (status.indexOf('stopped') !== -1) {
return 'danger';
}
return 'success';
};
})
.filter('containerstatus', function () {
'use strict';
return function (text) {
var status = _.toLower(text);
if (status.indexOf('paused') !== -1) {
return 'paused';
} else if (status.indexOf('created') !== -1) {
return 'created';
} else if (status.indexOf('exited') !== -1) {
return 'stopped';
}
return 'running';
};
})
.filter('nodestatusbadge', function () {
'use strict';
return function (text) {
if (text === 'down' || text === 'Unhealthy') {
return 'danger';
}
return 'success';
};
})
.filter('trimcontainername', function () {
'use strict';
return function (name) {
if (name) {
return (name.indexOf('/') === 0 ? name.replace('/','') : name);
}
return '';
};
})
.filter('capitalize', function () {
'use strict';
return function (text) {
return _.capitalize(text);
};
})
.filter('getstatetext', function () {
'use strict';
return function (state) {
if (state === undefined) {
return '';
}
if (state.Ghost && state.Running) {
return 'Ghost';
}
if (state.Running && state.Paused) {
return 'Running (Paused)';
}
if (state.Running) {
return 'Running';
}
return 'Stopped';
};
})
.filter('stripprotocol', function() {
'use strict';
return function (url) {
return url.replace(/.*?:\/\//g, '');
};
})
.filter('getstatelabel', function () {
'use strict';
return function (state) {
if (state === undefined) {
return 'label-default';
}
if (state.Ghost && state.Running) {
return 'label-important';
}
if (state.Running) {
return 'label-success';
}
return 'label-default';
};
})
.filter('humansize', function () {
'use strict';
return function (bytes, round) {
if (!round) {
round = 1;
}
if (bytes || bytes === 0) {
return filesize(bytes, {base: 10, round: round});
}
};
})
.filter('containername', function () {
'use strict';
return function (container) {
var name = container.Names[0];
return name.substring(1, name.length);
};
})
.filter('swarmcontainername', function () {
'use strict';
return function (container) {
return _.split(container.Names[0], '/')[2];
};
})
.filter('swarmversion', function () {
'use strict';
return function (text) {
return _.split(text, '/')[1];
};
})
.filter('swarmhostname', function () {
'use strict';
return function (container) {
return _.split(container.Names[0], '/')[1];
};
})
.filter('repotags', function () {
'use strict';
return function (image) {
if (image.RepoTags && image.RepoTags.length > 0) {
var tag = image.RepoTags[0];
if (tag === '<none>:<none>') {
return [];
}
return image.RepoTags;
}
return [];
};
})
.filter('getisodatefromtimestamp', function () {
'use strict';
return function (timestamp) {
return moment.unix(timestamp).format('YYYY-MM-DD HH:mm:ss');
};
})
.filter('getisodate', function () {
'use strict';
return function (date) {
return moment(date).format('YYYY-MM-DD HH:mm:ss');
};
})
.filter('command', function () {
'use strict';
return function (command) {
if (command) {
return command.join(' ');
}
};
})
.filter('key', function () {
'use strict';
return function (pair, separator) {
return pair.slice(0, pair.indexOf(separator));
};
})
.filter('value', function () {
'use strict';
return function (pair, separator) {
return pair.slice(pair.indexOf(separator) + 1);
};
})
.filter('emptyobject', function () {
'use strict';
return function (obj) {
return _.isEmpty(obj);
};
})
.filter('ipaddress', function () {
'use strict';
return function (ip) {
return ip.slice(0, ip.indexOf('/'));
};
})
.filter('arraytostr', function () {
'use strict';
return function (arr, separator) {
if (arr) {
return _.join(arr, separator);
}
return '';
};
});
angular.module('portainer.filters', [])
.filter('truncate', function () {
'use strict';
return function (text, length, end) {
if (isNaN(length)) {
length = 10;
}
if (end === undefined) {
end = '...';
}
if (text.length <= length || text.length - end.length <= length) {
return text;
}
else {
return String(text).substring(0, length - end.length) + end;
}
};
})
.filter('taskstatusbadge', function () {
'use strict';
return function (text) {
var status = _.toLower(text);
if (status.indexOf('new') !== -1 || status.indexOf('allocated') !== -1 ||
status.indexOf('assigned') !== -1 || status.indexOf('accepted') !== -1) {
return 'info';
} else if (status.indexOf('pending') !== -1) {
return 'warning';
} else if (status.indexOf('shutdown') !== -1 || status.indexOf('failed') !== -1 ||
status.indexOf('rejected') !== -1) {
return 'danger';
} else if (status.indexOf('complete') !== -1) {
return 'primary';
}
return 'success';
};
})
.filter('containerstatusbadge', function () {
'use strict';
return function (text) {
var status = _.toLower(text);
if (status.indexOf('paused') !== -1) {
return 'warning';
} else if (status.indexOf('created') !== -1) {
return 'info';
} else if (status.indexOf('stopped') !== -1) {
return 'danger';
}
return 'success';
};
})
.filter('containerstatus', function () {
'use strict';
return function (text) {
var status = _.toLower(text);
if (status.indexOf('paused') !== -1) {
return 'paused';
} else if (status.indexOf('created') !== -1) {
return 'created';
} else if (status.indexOf('exited') !== -1) {
return 'stopped';
}
return 'running';
};
})
.filter('nodestatusbadge', function () {
'use strict';
return function (text) {
if (text === 'down' || text === 'Unhealthy') {
return 'danger';
}
return 'success';
};
})
.filter('trimcontainername', function () {
'use strict';
return function (name) {
if (name) {
return (name.indexOf('/') === 0 ? name.replace('/','') : name);
}
return '';
};
})
.filter('capitalize', function () {
'use strict';
return function (text) {
return _.capitalize(text);
};
})
.filter('getstatetext', function () {
'use strict';
return function (state) {
if (state === undefined) {
return '';
}
if (state.Ghost && state.Running) {
return 'Ghost';
}
if (state.Running && state.Paused) {
return 'Running (Paused)';
}
if (state.Running) {
return 'Running';
}
return 'Stopped';
};
})
.filter('stripprotocol', function() {
'use strict';
return function (url) {
return url.replace(/.*?:\/\//g, '');
};
})
.filter('getstatelabel', function () {
'use strict';
return function (state) {
if (state === undefined) {
return 'label-default';
}
if (state.Ghost && state.Running) {
return 'label-important';
}
if (state.Running) {
return 'label-success';
}
return 'label-default';
};
})
.filter('humansize', function () {
'use strict';
return function (bytes, round) {
if (!round) {
round = 1;
}
if (bytes || bytes === 0) {
return filesize(bytes, {base: 10, round: round});
}
};
})
.filter('containername', function () {
'use strict';
return function (container) {
var name = container.Names[0];
return name.substring(1, name.length);
};
})
.filter('swarmcontainername', function () {
'use strict';
return function (container) {
return _.split(container.Names[0], '/')[2];
};
})
.filter('swarmversion', function () {
'use strict';
return function (text) {
return _.split(text, '/')[1];
};
})
.filter('swarmhostname', function () {
'use strict';
return function (container) {
return _.split(container.Names[0], '/')[1];
};
})
.filter('repotags', function () {
'use strict';
return function (image) {
if (image.RepoTags && image.RepoTags.length > 0) {
var tag = image.RepoTags[0];
if (tag === '<none>:<none>') {
return [];
}
return image.RepoTags;
}
return [];
};
})
.filter('getisodatefromtimestamp', function () {
'use strict';
return function (timestamp) {
return moment.unix(timestamp).format('YYYY-MM-DD HH:mm:ss');
};
})
.filter('getisodate', function () {
'use strict';
return function (date) {
return moment(date).format('YYYY-MM-DD HH:mm:ss');
};
})
.filter('command', function () {
'use strict';
return function (command) {
if (command) {
return command.join(' ');
}
};
})
.filter('key', function () {
'use strict';
return function (pair, separator) {
return pair.slice(0, pair.indexOf(separator));
};
})
.filter('value', function () {
'use strict';
return function (pair, separator) {
return pair.slice(pair.indexOf(separator) + 1);
};
})
.filter('emptyobject', function () {
'use strict';
return function (obj) {
return _.isEmpty(obj);
};
})
.filter('ipaddress', function () {
'use strict';
return function (ip) {
return ip.slice(0, ip.indexOf('/'));
};
})
.filter('arraytostr', function () {
'use strict';
return function (arr, separator) {
if (arr) {
return _.join(arr, separator);
}
return '';
};
})
.filter('hideshasum', function () {
'use strict';
return function (imageName) {
if (imageName) {
return imageName.split('@sha')[0];
}
return '';
};
});

View File

@@ -0,0 +1,26 @@
angular.module('portainer.helpers')
.factory('ContainerHelper', [function ContainerHelperFactory() {
'use strict';
var helper = {};
helper.commandStringToArray = function(command) {
return splitargs(command);
};
helper.hideContainers = function(containers, containersToHideLabels) {
return containers.filter(function (container) {
var filterContainer = false;
containersToHideLabels.forEach(function(label, index) {
if (_.has(container.Labels, label.name) &&
container.Labels[label.name] === label.value) {
filterContainer = true;
}
});
if (!filterContainer) {
return container;
}
});
};
return helper;
}]);

View File

@@ -0,0 +1,30 @@
angular.module('portainer.helpers')
.factory('ImageHelper', [function ImageHelperFactory() {
'use strict';
return {
createImageConfigForCommit: function(imageName, registry) {
var imageNameAndTag = imageName.split(':');
var image = imageNameAndTag[0];
if (registry) {
image = registry + '/' + imageNameAndTag[0];
}
var imageConfig = {
repo: image,
tag: imageNameAndTag[1] ? imageNameAndTag[1] : 'latest'
};
return imageConfig;
},
createImageConfigForContainer: function (imageName, registry) {
var imageNameAndTag = imageName.split(':');
var image = imageNameAndTag[0];
if (registry) {
image = registry + '/' + imageNameAndTag[0];
}
var imageConfig = {
fromImage: image,
tag: imageNameAndTag[1] ? imageNameAndTag[1] : 'latest'
};
return imageConfig;
}
};
}]);

32
app/helpers/infoHelper.js Normal file
View File

@@ -0,0 +1,32 @@
angular.module('portainer.helpers')
.factory('InfoHelper', [function InfoHelperFactory() {
'use strict';
return {
determineEndpointMode: function(info) {
var mode = {
provider: '',
role: ''
};
if (_.startsWith(info.ServerVersion, 'swarm')) {
mode.provider = "DOCKER_SWARM";
if (info.SystemStatus[0][1] === 'primary') {
mode.role = "PRIMARY";
} else {
mode.role = "REPLICA";
}
} else {
if (!info.Swarm || _.isEmpty(info.Swarm.NodeID)) {
mode.provider = "DOCKER_STANDALONE";
} else {
mode.provider = "DOCKER_SWARM_MODE";
if (info.Swarm.ControlAvailable) {
mode.role = "MANAGER";
} else {
mode.role = "WORKER";
}
}
}
return mode;
}
};
}]);

View File

@@ -0,0 +1,23 @@
angular.module('portainer.helpers')
.factory('LabelHelper', [function LabelHelperFactory() {
'use strict';
return {
fromLabelHashToKeyValue: function(labels) {
if (labels) {
return Object.keys(labels).map(function(key) {
return {key: key, value: labels[key], originalKey: key, originalValue: labels[key], added: true};
});
}
return [];
},
fromKeyValueToLabelHash: function(labelKV) {
var labels = {};
if (labelKV) {
labelKV.forEach(function(label) {
labels[label.key] = label.value;
});
}
return labels;
}
};
}]);

24
app/helpers/nodeHelper.js Normal file
View File

@@ -0,0 +1,24 @@
angular.module('portainer.helpers')
.factory('NodeHelper', [function NodeHelperFactory() {
'use strict';
return {
nodeToConfig: function(node) {
return {
Name: node.Spec.Name,
Role: node.Spec.Role,
Labels: node.Spec.Labels,
Availability: node.Spec.Availability
};
},
getManagerIP: function(nodes) {
var managerIp;
for (var n in nodes) {
if (undefined === nodes[n].ManagerStatus || nodes[n].ManagerStatus.Reachability !== "reachable") {
continue;
}
managerIp = nodes[n].ManagerStatus.Addr.split(":")[0];
}
return managerIp;
}
};
}]);

View File

@@ -0,0 +1,17 @@
angular.module('portainer.helpers')
.factory('ServiceHelper', [function ServiceHelperFactory() {
'use strict';
return {
serviceToConfig: function(service) {
return {
Name: service.Spec.Name,
Labels: service.Spec.Labels,
TaskTemplate: service.Spec.TaskTemplate,
Mode: service.Spec.Mode,
UpdateConfig: service.Spec.UpdateConfig,
Networks: service.Spec.Networks,
EndpointSpec: service.Spec.EndpointSpec
};
}
};
}]);

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